forked from mirrors/forgejo
feat: add OIDC workload identity federation support (#10481)
Add support for OIDC workload identity federation.
Add ID_TOKEN_SIGNING_ALGORITHM, ID_TOKEN_SIGNING_PRIVATE_KEY_FILE, and
ID_TOKEN_EXPIRATION_TIME settings to settings.actions to allow for admin
configuration of this functionality.
Add OIDC endpoints (/.well-known/openid-configuration and /.well-known/keys)
underneath the "/api/actions" route.
Add a token generation endpoint (/_apis/pipelines/workflows/{run_id}/idtoken)
underneath the "/api/actions" route.
Depends on: https://code.forgejo.org/forgejo/runner/pulls/1232
Docs PR: https://codeberg.org/forgejo/docs/pulls/1667
Signed-off-by: Mario Minardi <mminardi@shaw.ca>
## Checklist
The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. 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
- I added test coverage for Go changes...
- [x] in their respective `*_test.go` for unit tests.
- [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I added test coverage for JavaScript changes...
- [ ] in `web_src/js/*.test.js` if it can be unit tested.
- [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).
### Documentation
- [x] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [ ] I did not document these changes and I do not expect someone else to do it.
### Release notes
- [ ] I do not want this change to show in the release notes.
- [ ] I want the title to show in the release notes with a link to this pull request.
- [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title.
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10481
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Co-authored-by: Mario Minardi <mminardi@shaw.ca>
Co-committed-by: Mario Minardi <mminardi@shaw.ca>
This commit is contained in:
parent
f6ca985739
commit
c84cbd56a1
24 changed files with 1473 additions and 308 deletions
|
|
@ -2791,6 +2791,15 @@ LEVEL = Info
|
|||
;; server and database workload due to more complex database queries and more frequent server task querying; this
|
||||
;; feature can be disabled to reduce performance impact
|
||||
;CONCURRENCY_GROUP_QUEUE_ENABLED = true
|
||||
;; Algorithm used to sign ID tokens. Valid values: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512, EdDSA.
|
||||
;; RS256 will ensure compatibility with all relying parties.
|
||||
;; If a different algorithm is chosen, verify that relying parties of interest support the signing algorithm.
|
||||
;ID_TOKEN_SIGNING_ALGORITHM = RS256
|
||||
;; Private key file path used to sign ID tokens. The path is relative to APP_DATA_PATH.
|
||||
;; The file must contain an RSA or ECDSA private key in the PKCS8 format. If no key exists, a key will be created for you.
|
||||
;ID_TOKEN_SIGNING_PRIVATE_KEY_FILE = actions_id_token/private.pem
|
||||
;; Lifetime of ID tokens generated by the actions `/idtoken` endpoint in seconds.
|
||||
;ID_TOKEN_EXPIRATION_TIME = 3600
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
|
|
|||
|
|
@ -306,3 +306,12 @@ func (job *ActionRunJob) HasIncompleteWith() (bool, *jobparser.IncompleteNeeds,
|
|||
}
|
||||
return jobWorkflow.IncompleteWith, jobWorkflow.IncompleteWithNeeds, jobWorkflow.IncompleteWithMatrix, nil
|
||||
}
|
||||
|
||||
// EnableOpenIDConnect checks whether the job allows for ID token generation.
|
||||
func (job *ActionRunJob) EnableOpenIDConnect() (bool, error) {
|
||||
jobWorkflow, err := job.DecodeWorkflowPayload()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failure decoding workflow payload: %w", err)
|
||||
}
|
||||
return jobWorkflow.EnableOpenIDConnect, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
package jwtx
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
|
|
@ -16,10 +16,10 @@ import (
|
|||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/util"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
|
@ -34,8 +34,8 @@ func (err ErrInvalidAlgorithmType) Error() string {
|
|||
return fmt.Sprintf("JWT signing algorithm is not supported: %s", err.Algorithm)
|
||||
}
|
||||
|
||||
// JWTSigningKey represents a algorithm/key pair to sign JWTs
|
||||
type JWTSigningKey interface {
|
||||
// SigningKey represents a algorithm/key pair to sign JWTs
|
||||
type SigningKey interface {
|
||||
IsSymmetric() bool
|
||||
SigningMethod() jwt.SigningMethod
|
||||
SignKey() any
|
||||
|
|
@ -228,8 +228,8 @@ func (key ecdsaSingingKey) PreProcessToken(token *jwt.Token) {
|
|||
token.Header["kid"] = key.id
|
||||
}
|
||||
|
||||
// CreateJWTSigningKey creates a signing key from an algorithm / key pair.
|
||||
func CreateJWTSigningKey(algorithm string, key any) (JWTSigningKey, error) {
|
||||
// CreateSigningKey creates a signing key from an algorithm / key pair.
|
||||
func CreateSigningKey(algorithm string, key any) (SigningKey, error) {
|
||||
var signingMethod jwt.SigningMethod
|
||||
switch algorithm {
|
||||
case "HS256":
|
||||
|
|
@ -286,58 +286,9 @@ func CreateJWTSigningKey(algorithm string, key any) (JWTSigningKey, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// DefaultSigningKey is the default signing key for JWTs.
|
||||
var DefaultSigningKey JWTSigningKey
|
||||
|
||||
// InitSigningKey creates the default signing key from settings or creates a random key.
|
||||
func InitSigningKey() error {
|
||||
var err error
|
||||
var key any
|
||||
|
||||
switch setting.OAuth2.JWTSigningAlgorithm {
|
||||
case "HS256":
|
||||
fallthrough
|
||||
case "HS384":
|
||||
fallthrough
|
||||
case "HS512":
|
||||
key = setting.GetGeneralTokenSigningSecret()
|
||||
case "RS256":
|
||||
fallthrough
|
||||
case "RS384":
|
||||
fallthrough
|
||||
case "RS512":
|
||||
fallthrough
|
||||
case "ES256":
|
||||
fallthrough
|
||||
case "ES384":
|
||||
fallthrough
|
||||
case "ES512":
|
||||
fallthrough
|
||||
case "EdDSA":
|
||||
key, err = loadOrCreateAsymmetricKey()
|
||||
default:
|
||||
return ErrInvalidAlgorithmType{setting.OAuth2.JWTSigningAlgorithm}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error while loading or creating JWT key: %w", err)
|
||||
}
|
||||
|
||||
signingKey, err := CreateJWTSigningKey(setting.OAuth2.JWTSigningAlgorithm, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
DefaultSigningKey = signingKey
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadOrCreateAsymmetricKey checks if the configured private key exists.
|
||||
// If it does not exist a new random key gets generated and saved on the configured path.
|
||||
func loadOrCreateAsymmetricKey() (any, error) {
|
||||
keyPath := setting.OAuth2.JWTSigningPrivateKeyFile
|
||||
|
||||
func loadOrCreateAsymmetricKey(keyPath, algorithm string) (any, error) {
|
||||
isExist, err := util.IsExist(keyPath)
|
||||
if err != nil {
|
||||
log.Fatal("Unable to check if %s exists. Error: %v", keyPath, err)
|
||||
|
|
@ -346,9 +297,9 @@ func loadOrCreateAsymmetricKey() (any, error) {
|
|||
err := func() error {
|
||||
key, err := func() (any, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(setting.OAuth2.JWTSigningAlgorithm, "RS"):
|
||||
case strings.HasPrefix(algorithm, "RS"):
|
||||
var bits int
|
||||
switch setting.OAuth2.JWTSigningAlgorithm {
|
||||
switch algorithm {
|
||||
case "RS256":
|
||||
bits = 2048
|
||||
case "RS384":
|
||||
|
|
@ -357,12 +308,12 @@ func loadOrCreateAsymmetricKey() (any, error) {
|
|||
bits = 4096
|
||||
}
|
||||
return rsa.GenerateKey(rand.Reader, bits)
|
||||
case setting.OAuth2.JWTSigningAlgorithm == "EdDSA":
|
||||
case algorithm == "EdDSA":
|
||||
_, pk, err := ed25519.GenerateKey(rand.Reader)
|
||||
return pk, err
|
||||
default:
|
||||
var curve elliptic.Curve
|
||||
switch setting.OAuth2.JWTSigningAlgorithm {
|
||||
switch algorithm {
|
||||
case "ES256":
|
||||
curve = elliptic.P256()
|
||||
case "ES384":
|
||||
|
|
@ -420,3 +371,71 @@ func loadOrCreateAsymmetricKey() (any, error) {
|
|||
|
||||
return x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
}
|
||||
|
||||
// InitSigningKey creates a signing key from settings or creates a random key.
|
||||
func InitSigningKey(getGeneralTokenSigningSecret func() []byte, keyPath, algorithm string) (SigningKey, error) {
|
||||
var err error
|
||||
var key SigningKey
|
||||
|
||||
key, err = InitSymmetricSigningKey(getGeneralTokenSigningSecret, algorithm)
|
||||
if err != nil {
|
||||
key, err = InitAsymmetricSigningKey(keyPath, algorithm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// IsValidSymmetricAlgorithm checks if the passed in algorithm is a supported symettric algorithm.
|
||||
func IsValidSymmetricAlgorithm(algorithm string) bool {
|
||||
validAlgs := []string{"HS256", "HS384", "HS512"}
|
||||
|
||||
return slices.Contains(validAlgs, algorithm)
|
||||
}
|
||||
|
||||
// InitSymmetricSigningKey creates a symmetric signing key from settings.
|
||||
func InitSymmetricSigningKey(getGeneralTokenSigningSecret func() []byte, algorithm string) (SigningKey, error) {
|
||||
var err error
|
||||
|
||||
if !IsValidSymmetricAlgorithm(algorithm) {
|
||||
return nil, fmt.Errorf("invalid algorithm: %s", algorithm)
|
||||
}
|
||||
|
||||
signingKey, err := CreateSigningKey(algorithm, getGeneralTokenSigningSecret())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return signingKey, nil
|
||||
}
|
||||
|
||||
// IsValidAsymmetricAlgorithm checks if the passed in algorithm is a supported asymmetric algorithm.
|
||||
func IsValidAsymmetricAlgorithm(algorithm string) bool {
|
||||
validAlgs := []string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "EdDSA"}
|
||||
|
||||
return slices.Contains(validAlgs, algorithm)
|
||||
}
|
||||
|
||||
// InitAsymmetricSigningKey creates an asymmetric signing key from settings or creates a random key.
|
||||
func InitAsymmetricSigningKey(keyPath, algorithm string) (SigningKey, error) {
|
||||
var err error
|
||||
var key any
|
||||
|
||||
if !IsValidAsymmetricAlgorithm(algorithm) {
|
||||
return nil, ErrInvalidAlgorithmType{Algorithm: algorithm}
|
||||
}
|
||||
|
||||
key, err = loadOrCreateAsymmetricKey(keyPath, algorithm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error while loading or creating JWT key: %w", err)
|
||||
}
|
||||
|
||||
signingKey, err := CreateSigningKey(algorithm, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return signingKey, nil
|
||||
}
|
||||
113
modules/jwtx/signingkey_test.go
Normal file
113
modules/jwtx/signingkey_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package jwtx
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadOrCreateAsymmetricKey(t *testing.T) {
|
||||
loadKey := func(t *testing.T, keyPath, algorithm string) any {
|
||||
t.Helper()
|
||||
loadOrCreateAsymmetricKey(keyPath, algorithm)
|
||||
|
||||
fileContent, err := os.ReadFile(keyPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
block, _ := pem.Decode(fileContent)
|
||||
assert.NotNil(t, block)
|
||||
assert.Equal(t, "PRIVATE KEY", block.Type)
|
||||
|
||||
parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
require.NoError(t, err)
|
||||
|
||||
return parsedKey
|
||||
}
|
||||
t.Run("RSA-2048", func(t *testing.T) {
|
||||
keyPath := filepath.Join(t.TempDir(), "jwt-rsa-2048.priv")
|
||||
algorithm := "RS256"
|
||||
|
||||
parsedKey := loadKey(t, keyPath, algorithm)
|
||||
|
||||
rsaPrivateKey := parsedKey.(*rsa.PrivateKey)
|
||||
assert.Equal(t, 2048, rsaPrivateKey.N.BitLen())
|
||||
|
||||
t.Run("Load key with differ specified algorithm", func(t *testing.T) {
|
||||
algorithm = "EdDSA"
|
||||
|
||||
parsedKey := loadKey(t, keyPath, algorithm)
|
||||
rsaPrivateKey := parsedKey.(*rsa.PrivateKey)
|
||||
assert.Equal(t, 2048, rsaPrivateKey.N.BitLen())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("RSA-3072", func(t *testing.T) {
|
||||
keyPath := filepath.Join(t.TempDir(), "jwt-rsa-3072.priv")
|
||||
algorithm := "RS384"
|
||||
|
||||
parsedKey := loadKey(t, keyPath, algorithm)
|
||||
|
||||
rsaPrivateKey := parsedKey.(*rsa.PrivateKey)
|
||||
assert.Equal(t, 3072, rsaPrivateKey.N.BitLen())
|
||||
})
|
||||
|
||||
t.Run("RSA-4096", func(t *testing.T) {
|
||||
keyPath := filepath.Join(t.TempDir(), "jwt-rsa-4096.priv")
|
||||
algorithm := "RS512"
|
||||
|
||||
parsedKey := loadKey(t, keyPath, algorithm)
|
||||
|
||||
rsaPrivateKey := parsedKey.(*rsa.PrivateKey)
|
||||
assert.Equal(t, 4096, rsaPrivateKey.N.BitLen())
|
||||
})
|
||||
|
||||
t.Run("ECDSA-256", func(t *testing.T) {
|
||||
keyPath := filepath.Join(t.TempDir(), "jwt-ecdsa-256.priv")
|
||||
algorithm := "ES256"
|
||||
|
||||
parsedKey := loadKey(t, keyPath, algorithm)
|
||||
|
||||
ecdsaPrivateKey := parsedKey.(*ecdsa.PrivateKey)
|
||||
assert.Equal(t, 256, ecdsaPrivateKey.Params().BitSize)
|
||||
})
|
||||
|
||||
t.Run("ECDSA-384", func(t *testing.T) {
|
||||
keyPath := filepath.Join(t.TempDir(), "jwt-ecdsa-384.priv")
|
||||
algorithm := "ES384"
|
||||
|
||||
parsedKey := loadKey(t, keyPath, algorithm)
|
||||
|
||||
ecdsaPrivateKey := parsedKey.(*ecdsa.PrivateKey)
|
||||
assert.Equal(t, 384, ecdsaPrivateKey.Params().BitSize)
|
||||
})
|
||||
|
||||
t.Run("ECDSA-512", func(t *testing.T) {
|
||||
keyPath := filepath.Join(t.TempDir(), "jwt-ecdsa-512.priv")
|
||||
algorithm := "ES512"
|
||||
|
||||
parsedKey := loadKey(t, keyPath, algorithm)
|
||||
|
||||
ecdsaPrivateKey := parsedKey.(*ecdsa.PrivateKey)
|
||||
assert.Equal(t, 521, ecdsaPrivateKey.Params().BitSize)
|
||||
})
|
||||
|
||||
t.Run("EdDSA", func(t *testing.T) {
|
||||
keyPath := filepath.Join(t.TempDir(), "jwt-eddsa.priv")
|
||||
algorithm := "EdDSA"
|
||||
|
||||
parsedKey := loadKey(t, keyPath, algorithm)
|
||||
|
||||
assert.NotNil(t, parsedKey.(ed25519.PrivateKey))
|
||||
})
|
||||
}
|
||||
|
|
@ -5,8 +5,11 @@ package setting
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forgejo.org/modules/jwtx"
|
||||
)
|
||||
|
||||
// Actions settings
|
||||
|
|
@ -25,12 +28,18 @@ var (
|
|||
SkipWorkflowStrings []string `ini:"SKIP_WORKFLOW_STRINGS"`
|
||||
LimitDispatchInputs int64 `ini:"LIMIT_DISPATCH_INPUTS"`
|
||||
ConcurrencyGroupQueueEnabled bool `ini:"CONCURRENCY_GROUP_QUEUE_ENABLED"`
|
||||
IDTokenSigningAlgorithm idTokenAlgorithm `ini:"ID_TOKEN_SIGNING_ALGORITHM"`
|
||||
IDTokenSigningPrivateKeyFile string `ini:"ID_TOKEN_SIGNING_PRIVATE_KEY_FILE"`
|
||||
IDTokenExpirationTime int64 `ini:"ID_TOKEN_EXPIRATION_TIME"`
|
||||
}{
|
||||
Enabled: true,
|
||||
DefaultActionsURL: defaultActionsURLForgejo,
|
||||
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
|
||||
LimitDispatchInputs: 100,
|
||||
ConcurrencyGroupQueueEnabled: true,
|
||||
IDTokenSigningAlgorithm: "RS256",
|
||||
IDTokenSigningPrivateKeyFile: "actions_id_token/private.pem",
|
||||
IDTokenExpirationTime: 3600,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -67,6 +76,13 @@ func (c logCompression) IsZstd() bool {
|
|||
return c == "" || strings.ToLower(string(c)) == "zstd"
|
||||
}
|
||||
|
||||
type idTokenAlgorithm string
|
||||
|
||||
func (c idTokenAlgorithm) IsValid() bool {
|
||||
// Empty string implies RS256
|
||||
return jwtx.IsValidAsymmetricAlgorithm(string(c)) || string(c) == ""
|
||||
}
|
||||
|
||||
func loadActionsFrom(rootCfg ConfigProvider) error {
|
||||
sec := rootCfg.Section("actions")
|
||||
err := sec.MapTo(&Actions)
|
||||
|
|
@ -104,5 +120,13 @@ func loadActionsFrom(rootCfg ConfigProvider) error {
|
|||
return fmt.Errorf("invalid [actions] LOG_COMPRESSION: %q", Actions.LogCompression)
|
||||
}
|
||||
|
||||
if !Actions.IDTokenSigningAlgorithm.IsValid() {
|
||||
return fmt.Errorf("invalid [actions] ID_TOKEN_SIGNING_ALGORITHM: %q", Actions.IDTokenSigningAlgorithm)
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(Actions.IDTokenSigningPrivateKeyFile) {
|
||||
Actions.IDTokenSigningPrivateKeyFile = filepath.Join(AppDataPath, Actions.IDTokenSigningPrivateKeyFile)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import (
|
|||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"forgejo.org/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -155,3 +157,62 @@ DEFAULT_ACTIONS_URL = https://example.com
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getIDTokenSettingsForActions(t *testing.T) {
|
||||
defer test.MockVariableValue(&AppDataPath, "/home/app/data")()
|
||||
|
||||
oldActions := Actions
|
||||
oldAppURL := AppURL
|
||||
defer func() {
|
||||
Actions = oldActions
|
||||
AppURL = oldAppURL
|
||||
}()
|
||||
|
||||
iniStr := `
|
||||
[actions]
|
||||
`
|
||||
cfg, err := NewConfigProviderFromData(iniStr)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, loadActionsFrom(cfg))
|
||||
|
||||
assert.EqualValues(t, "RS256", Actions.IDTokenSigningAlgorithm)
|
||||
assert.Equal(t, "/home/app/data/actions_id_token/private.pem", Actions.IDTokenSigningPrivateKeyFile)
|
||||
assert.EqualValues(t, 3600, Actions.IDTokenExpirationTime)
|
||||
|
||||
iniStr = `
|
||||
[actions]
|
||||
ID_TOKEN_SIGNING_ALGORITHM = ES256
|
||||
ID_TOKEN_SIGNING_PRIVATE_KEY_FILE = /test/test.pem
|
||||
ID_TOKEN_EXPIRATION_TIME = 120
|
||||
`
|
||||
cfg, err = NewConfigProviderFromData(iniStr)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, loadActionsFrom(cfg))
|
||||
|
||||
assert.EqualValues(t, "ES256", Actions.IDTokenSigningAlgorithm)
|
||||
assert.Equal(t, "/test/test.pem", Actions.IDTokenSigningPrivateKeyFile)
|
||||
assert.EqualValues(t, 120, Actions.IDTokenExpirationTime)
|
||||
|
||||
iniStr = `
|
||||
[actions]
|
||||
ID_TOKEN_SIGNING_ALGORITHM = EdDSA
|
||||
ID_TOKEN_SIGNING_PRIVATE_KEY_FILE = ./test/test.pem
|
||||
ID_TOKEN_EXPIRATION_TIME = 123
|
||||
`
|
||||
cfg, err = NewConfigProviderFromData(iniStr)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, loadActionsFrom(cfg))
|
||||
|
||||
assert.EqualValues(t, "EdDSA", Actions.IDTokenSigningAlgorithm)
|
||||
assert.Equal(t, "/home/app/data/test/test.pem", Actions.IDTokenSigningPrivateKeyFile)
|
||||
assert.EqualValues(t, 123, Actions.IDTokenExpirationTime)
|
||||
|
||||
iniStr = `
|
||||
[actions]
|
||||
ID_TOKEN_SIGNING_ALGORITHM = HS256
|
||||
`
|
||||
cfg, err = NewConfigProviderFromData(iniStr)
|
||||
require.NoError(t, err)
|
||||
err = loadActionsFrom(cfg)
|
||||
require.Errorf(t, err, "invalid [actions] ID_TOKEN_SIGNING_ALGORITHM %q", Actions.IDTokenSigningAlgorithm)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,5 +20,8 @@ func Routes(prefix string) *web.Route {
|
|||
path, handler = runner.NewRunnerServiceHandler()
|
||||
m.Post(path+"*", http.StripPrefix(prefix, handler).ServeHTTP)
|
||||
|
||||
m.Mount("/.well-known", OIDCRoutes(prefix))
|
||||
m.Get(idTokenRouteBase, IDTokenContexter(), generateIDToken)
|
||||
|
||||
return m
|
||||
}
|
||||
|
|
|
|||
151
routers/api/actions/id_token.go
Normal file
151
routers/api/actions/id_token.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forgejo.org/models/actions"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/timeutil"
|
||||
"forgejo.org/modules/web"
|
||||
web_types "forgejo.org/modules/web/types"
|
||||
actions_service "forgejo.org/services/actions"
|
||||
"forgejo.org/services/context"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const idTokenRouteBase = "/_apis/pipelines/workflows/{run_id}/idtoken"
|
||||
|
||||
type idTokenContextKeyType struct{}
|
||||
|
||||
var idTokenContextKey = idTokenContextKeyType{}
|
||||
|
||||
type IDTokenContext struct {
|
||||
*context.Base
|
||||
|
||||
Audience string
|
||||
AuthorizationTokenClaims *actions_service.AuthorizationTokenClaims
|
||||
IDTokenCustomClaims *actions_service.IDTokenCustomClaims
|
||||
}
|
||||
|
||||
func init() {
|
||||
web.RegisterResponseStatusProvider[*IDTokenContext](func(req *http.Request) web_types.ResponseStatusProvider {
|
||||
return req.Context().Value(idTokenContextKey).(*IDTokenContext)
|
||||
})
|
||||
}
|
||||
|
||||
func IDTokenContexter() func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||
base, baseCleanUp := context.NewBaseContext(resp, req)
|
||||
defer baseCleanUp()
|
||||
|
||||
ctx := &IDTokenContext{Base: base}
|
||||
ctx.AppendContextValue(idTokenContextKey, ctx)
|
||||
|
||||
// action task call server api with Bearer ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
||||
// we should verify the ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
if len(authHeader) == 0 || !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
ctx.Error(http.StatusUnauthorized, "Bad authorization header")
|
||||
return
|
||||
}
|
||||
|
||||
// Require using new act_runner that uses jwt to authenticate
|
||||
authorizationTokenClaims, err := actions_service.ParseAuthorizationTokenClaims(req)
|
||||
if err != nil {
|
||||
log.Error("Error runner api parsing authorization token: %v", err)
|
||||
ctx.Error(http.StatusInternalServerError, "Error runner api parsing authorization token")
|
||||
return
|
||||
}
|
||||
|
||||
customClaims := &actions_service.IDTokenCustomClaims{}
|
||||
err = json.Unmarshal([]byte(authorizationTokenClaims.OIDCExtra), customClaims)
|
||||
if err != nil {
|
||||
log.Error("Error runner api parsing custom claims: %v", err)
|
||||
ctx.Error(http.StatusInternalServerError, "Error runner api parsing custom claims")
|
||||
}
|
||||
|
||||
task, err := actions.GetTaskByID(req.Context(), authorizationTokenClaims.TaskID)
|
||||
if err != nil {
|
||||
log.Error("Error runner api getting task by ID: %v", err)
|
||||
ctx.Error(http.StatusInternalServerError, "Error runner api getting task by ID")
|
||||
return
|
||||
}
|
||||
if task.Status != actions.StatusRunning {
|
||||
log.Error("Error runner api getting task: task is not running")
|
||||
ctx.Error(http.StatusInternalServerError, "Error runner api getting task: task is not running")
|
||||
return
|
||||
}
|
||||
err = task.LoadAttributes(req.Context())
|
||||
if err != nil {
|
||||
log.Error("Error runner api getting task attributes: %v", err)
|
||||
ctx.Error(http.StatusInternalServerError, "Error runner api getting task attributes")
|
||||
return
|
||||
}
|
||||
|
||||
runID := ctx.ParamsInt64("run_id")
|
||||
if task.Job.RunID != runID {
|
||||
log.Error("Error runID not match" + fmt.Sprint(task.Job.RunID) + " " + fmt.Sprint(runID))
|
||||
ctx.Error(http.StatusBadRequest, "run-id does not match")
|
||||
return
|
||||
}
|
||||
|
||||
audience := req.URL.Query().Get("audience")
|
||||
if audience == "" {
|
||||
// Default to organization that owns the repo if no audience is provided
|
||||
audience = setting.AppURL + customClaims.RepositoryOwner
|
||||
}
|
||||
|
||||
ctx.AuthorizationTokenClaims = authorizationTokenClaims
|
||||
ctx.IDTokenCustomClaims = customClaims
|
||||
ctx.Audience = audience
|
||||
next.ServeHTTP(ctx.Resp, ctx.Req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func generateIDToken(ctx *IDTokenContext) {
|
||||
expirationDate := timeutil.TimeStampNow().Add(setting.Actions.IDTokenExpirationTime)
|
||||
|
||||
var claims jwt.MapClaims
|
||||
inrec, _ := json.Marshal(ctx.IDTokenCustomClaims)
|
||||
err := json.Unmarshal(inrec, &claims)
|
||||
if err != nil {
|
||||
ctx.Error(http.StatusInternalServerError, "Error generating token")
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
claims["sub"] = ctx.AuthorizationTokenClaims.OIDCSub
|
||||
claims["aud"] = ctx.Audience
|
||||
claims["exp"] = jwt.NewNumericDate(expirationDate.AsTime())
|
||||
claims["iat"] = jwt.NewNumericDate(now)
|
||||
claims["nbf"] = jwt.NewNumericDate(now)
|
||||
claims["iss"] = strings.TrimSuffix(setting.AppURL, "/") + "/api/actions"
|
||||
|
||||
jwtToken := jwt.NewWithClaims(jwtSigningKey.SigningMethod(), claims)
|
||||
jwtSigningKey.PreProcessToken(jwtToken)
|
||||
|
||||
signedToken, err := jwtToken.SignedString(jwtSigningKey.SignKey())
|
||||
if err != nil {
|
||||
ctx.Error(http.StatusInternalServerError, "Error signing token")
|
||||
}
|
||||
|
||||
resp := IDTokenResponse{
|
||||
Value: signedToken,
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type IDTokenResponse struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
146
routers/api/actions/oidc.go
Normal file
146
routers/api/actions/oidc.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"forgejo.org/modules/jwtx"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/web"
|
||||
web_types "forgejo.org/modules/web/types"
|
||||
actions_service "forgejo.org/services/actions"
|
||||
"forgejo.org/services/context"
|
||||
)
|
||||
|
||||
type oidcRoutes struct {
|
||||
openIDConfiguration openIDConfiguration
|
||||
jwks map[string][]map[string]string
|
||||
}
|
||||
|
||||
type openIDConfiguration struct {
|
||||
Issuer string `json:"issuer"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
}
|
||||
|
||||
type oidcContextKeyType struct{}
|
||||
|
||||
var oidcContextKey = oidcContextKeyType{}
|
||||
|
||||
// jwtSigningKey is the default signing key for JWTs.
|
||||
var jwtSigningKey jwtx.SigningKey
|
||||
|
||||
// jwk is the JWK format of the jwtSigningKey.
|
||||
var jwk map[string]string
|
||||
|
||||
type OIDCContext struct {
|
||||
*context.Base
|
||||
}
|
||||
|
||||
func InitOIDC() error {
|
||||
var err error
|
||||
jwtSigningKey, err = jwtx.InitAsymmetricSigningKey(setting.Actions.IDTokenSigningPrivateKeyFile, string(setting.Actions.IDTokenSigningAlgorithm))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jwk, err = jwtSigningKey.ToJWK()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error getting JWK from default signing key: %v", err)
|
||||
}
|
||||
jwk["use"] = "sig"
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
web.RegisterResponseStatusProvider[*OIDCContext](func(req *http.Request) web_types.ResponseStatusProvider {
|
||||
return req.Context().Value(oidcContextKey).(*OIDCContext)
|
||||
})
|
||||
}
|
||||
|
||||
func OIDCContexter() func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||
base, baseCleanUp := context.NewBaseContext(resp, req)
|
||||
defer baseCleanUp()
|
||||
|
||||
ctx := &OIDCContext{Base: base}
|
||||
ctx.AppendContextValue(oidcContextKey, ctx)
|
||||
|
||||
next.ServeHTTP(ctx.Resp, ctx.Req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func OIDCRoutes(prefix string) *web.Route {
|
||||
m := web.NewRoute()
|
||||
|
||||
prefix = strings.TrimPrefix(prefix, "/")
|
||||
|
||||
// Standard claims
|
||||
claimsSupported := []string{
|
||||
"sub",
|
||||
"aud",
|
||||
"exp",
|
||||
"iat",
|
||||
"iss",
|
||||
"nbf",
|
||||
}
|
||||
|
||||
// Add custom claims by iterating over [actions_service.IDTokenCustomClaims]
|
||||
// and inspecting the names of the json struct tags
|
||||
customClaims := actions_service.IDTokenCustomClaims{}
|
||||
rt := reflect.TypeOf(customClaims)
|
||||
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
f := rt.Field(i)
|
||||
v := strings.Split(f.Tag.Get("json"), ",")[0]
|
||||
if v == "" || v == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
claimsSupported = append(claimsSupported, v)
|
||||
}
|
||||
|
||||
o := &oidcRoutes{
|
||||
openIDConfiguration: openIDConfiguration{
|
||||
Issuer: setting.AppURL + prefix,
|
||||
JwksURI: setting.AppURL + prefix + "/.well-known/keys",
|
||||
SubjectTypesSupported: []string{"public"},
|
||||
ResponseTypesSupported: []string{"id_token"},
|
||||
ClaimsSupported: claimsSupported,
|
||||
IDTokenSigningAlgValuesSupported: []string{string(setting.Actions.IDTokenSigningAlgorithm)},
|
||||
ScopesSupported: []string{"openid"},
|
||||
},
|
||||
jwks: map[string][]map[string]string{
|
||||
"keys": {
|
||||
jwk,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
m.Group("", func() {
|
||||
m.Get("/keys", o.keys)
|
||||
m.Get("/openid-configuration", o.configuration)
|
||||
}, OIDCContexter())
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (o *oidcRoutes) configuration(ctx *OIDCContext) {
|
||||
ctx.JSON(http.StatusOK, o.openIDConfiguration)
|
||||
}
|
||||
|
||||
func (o *oidcRoutes) keys(ctx *OIDCContext) {
|
||||
ctx.JSON(http.StatusOK, o.jwks)
|
||||
}
|
||||
|
|
@ -171,6 +171,8 @@ func InitWebInstalled(ctx context.Context) {
|
|||
actions_service.Init()
|
||||
mustInit(stats.Init)
|
||||
|
||||
mustInit(actions_router.InitOIDC)
|
||||
|
||||
// Finally start up the cron
|
||||
cron.NewContext(ctx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import (
|
|||
"forgejo.org/modules/base"
|
||||
"forgejo.org/modules/container"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/jwtx"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/optional"
|
||||
"forgejo.org/modules/setting"
|
||||
|
|
@ -153,7 +154,7 @@ type AccessTokenResponse struct {
|
|||
IDToken string `json:"id_token,omitempty"`
|
||||
}
|
||||
|
||||
func newAccessTokenResponse(ctx go_context.Context, grant *auth.OAuth2Grant, serverKey, clientKey oauth2.JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
|
||||
func newAccessTokenResponse(ctx go_context.Context, grant *auth.OAuth2Grant, serverKey, clientKey jwtx.SigningKey) (*AccessTokenResponse, *AccessTokenError) {
|
||||
if setting.OAuth2.InvalidateRefreshTokens {
|
||||
if err := grant.IncreaseCounter(ctx); err != nil {
|
||||
return nil, &AccessTokenError{
|
||||
|
|
@ -741,7 +742,7 @@ func AccessTokenOAuth(ctx *context.Context) {
|
|||
clientKey := serverKey
|
||||
if serverKey.IsSymmetric() {
|
||||
var err error
|
||||
clientKey, err = oauth2.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret))
|
||||
clientKey, err = jwtx.CreateSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret))
|
||||
if err != nil {
|
||||
handleAccessTokenError(ctx, AccessTokenError{
|
||||
ErrorCode: AccessTokenErrorCodeInvalidRequest,
|
||||
|
|
@ -764,7 +765,7 @@ func AccessTokenOAuth(ctx *context.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
|
||||
func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey jwtx.SigningKey) {
|
||||
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
|
||||
if err != nil {
|
||||
handleAccessTokenError(ctx, AccessTokenError{
|
||||
|
|
@ -824,7 +825,7 @@ func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, server
|
|||
ctx.JSON(http.StatusOK, accessToken)
|
||||
}
|
||||
|
||||
func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
|
||||
func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey jwtx.SigningKey) {
|
||||
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
|
||||
if err != nil {
|
||||
handleAccessTokenError(ctx, AccessTokenError{
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"forgejo.org/models/db"
|
||||
"forgejo.org/models/unittest"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/jwtx"
|
||||
"forgejo.org/modules/timeutil"
|
||||
"forgejo.org/services/auth/source/oauth2"
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ import (
|
|||
)
|
||||
|
||||
func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2.OIDCToken {
|
||||
signingKey, err := oauth2.CreateJWTSigningKey("HS256", make([]byte, 32))
|
||||
signingKey, err := jwtx.CreateSigningKey("HS256", make([]byte, 32))
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, signingKey)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
actions_model "forgejo.org/models/actions"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/setting"
|
||||
|
|
@ -17,13 +18,33 @@ import (
|
|||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type actionsClaims struct {
|
||||
type AuthorizationTokenClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Scp string `json:"scp"`
|
||||
TaskID int64
|
||||
RunID int64
|
||||
JobID int64
|
||||
Ac string `json:"ac"`
|
||||
Scp string `json:"scp"`
|
||||
TaskID int64
|
||||
RunID int64
|
||||
JobID int64
|
||||
Ac string `json:"ac"`
|
||||
OIDCExtra string `json:"oidc_extra,omitempty"`
|
||||
OIDCSub string `json:"oidc_sub,omitempty"`
|
||||
}
|
||||
|
||||
type IDTokenCustomClaims struct {
|
||||
Actor string `json:"actor"`
|
||||
BaseRef string `json:"base_ref"`
|
||||
EventName string `json:"event_name"`
|
||||
HeadRef string `json:"head_ref"`
|
||||
Ref string `json:"ref"`
|
||||
RefProtected string `json:"ref_protected"`
|
||||
RefType string `json:"ref_type"`
|
||||
Repository string `json:"repository"`
|
||||
RepositoryOwner string `json:"repository_owner"`
|
||||
RunAttempt string `json:"run_attempt"`
|
||||
RunID string `json:"run_id"`
|
||||
RunNumber string `json:"run_number"`
|
||||
Sha string `json:"sha"`
|
||||
Workflow string `json:"workflow"`
|
||||
WorkflowRef string `json:"workflow_ref"`
|
||||
}
|
||||
|
||||
type actionsCacheScope struct {
|
||||
|
|
@ -38,8 +59,11 @@ const (
|
|||
actionsCachePermissionWrite
|
||||
)
|
||||
|
||||
func CreateAuthorizationToken(taskID, runID, jobID int64) (string, error) {
|
||||
func CreateAuthorizationToken(task *actions_model.ActionTask, gitGtx map[string]any, enableOpenIDConnect bool) (string, error) {
|
||||
now := time.Now()
|
||||
taskID := task.ID
|
||||
runID := task.Job.RunID
|
||||
jobID := task.Job.ID
|
||||
|
||||
ac, err := json.Marshal(&[]actionsCacheScope{
|
||||
{
|
||||
|
|
@ -51,17 +75,31 @@ func CreateAuthorizationToken(taskID, runID, jobID int64) (string, error) {
|
|||
return "", err
|
||||
}
|
||||
|
||||
claims := actionsClaims{
|
||||
runIDJobID := fmt.Sprintf("%d:%d", runID, jobID)
|
||||
claims := AuthorizationTokenClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
Scp: fmt.Sprintf("Actions.Results:%d:%d", runID, jobID),
|
||||
Scp: fmt.Sprintf("Actions.Results:%s", runIDJobID),
|
||||
Ac: string(ac),
|
||||
TaskID: taskID,
|
||||
RunID: runID,
|
||||
JobID: jobID,
|
||||
}
|
||||
|
||||
// Only populate OIDC information if the task has OIDC enabled.
|
||||
if enableOpenIDConnect {
|
||||
oidcExtra, err := generateOIDCExtra(gitGtx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims.OIDCExtra = oidcExtra
|
||||
claims.OIDCSub = generateOIDCSub(gitGtx)
|
||||
claims.Scp = fmt.Sprintf("%s generate_id_token:%s", claims.Scp, runIDJobID)
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
tokenString, err := token.SignedString(setting.GetGeneralTokenSigningSecret())
|
||||
|
|
@ -72,24 +110,66 @@ func CreateAuthorizationToken(taskID, runID, jobID int64) (string, error) {
|
|||
return tokenString, nil
|
||||
}
|
||||
|
||||
func generateOIDCExtra(gitCtx map[string]any) (string, error) {
|
||||
ctxVal := func(key string) string {
|
||||
val, ok := gitCtx[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(val)
|
||||
}
|
||||
|
||||
claims := IDTokenCustomClaims{
|
||||
Actor: ctxVal("actor"),
|
||||
BaseRef: ctxVal("base_ref"),
|
||||
EventName: ctxVal("event_name"),
|
||||
HeadRef: ctxVal("head_ref"),
|
||||
Ref: ctxVal("ref"),
|
||||
RefProtected: ctxVal("ref_protected"),
|
||||
RefType: ctxVal("ref_type"),
|
||||
Repository: ctxVal("repository"),
|
||||
RepositoryOwner: ctxVal("repository_owner"),
|
||||
RunAttempt: ctxVal("run_attempt"),
|
||||
RunID: ctxVal("run_id"),
|
||||
RunNumber: ctxVal("run_number"),
|
||||
Sha: ctxVal("sha"),
|
||||
Workflow: ctxVal("workflow"),
|
||||
WorkflowRef: ctxVal("workflow_ref"),
|
||||
}
|
||||
|
||||
ret, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(ret), nil
|
||||
}
|
||||
|
||||
func generateOIDCSub(gitCtx map[string]any) string {
|
||||
switch gitCtx["event_name"] {
|
||||
case "pull_request":
|
||||
return fmt.Sprintf("repo:%s:pull_request", gitCtx["repository"])
|
||||
default:
|
||||
return fmt.Sprintf("repo:%s:ref:%s", gitCtx["repository"], gitCtx["ref"])
|
||||
}
|
||||
}
|
||||
|
||||
func ParseAuthorizationToken(req *http.Request) (int64, error) {
|
||||
h := req.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
token, err := parseTokenFromHeader(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(h, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
log.Error("split token failed: %s", h)
|
||||
return 0, errors.New("split token failed")
|
||||
}
|
||||
|
||||
return TokenToTaskID(parts[1])
|
||||
return TokenToTaskID(token)
|
||||
}
|
||||
|
||||
// TokenToTaskID returns the TaskID associated with the provided JWT token
|
||||
func TokenToTaskID(token string) (int64, error) {
|
||||
parsedToken, err := jwt.ParseWithClaims(token, &actionsClaims{}, func(t *jwt.Token) (any, error) {
|
||||
parsedToken, err := jwt.ParseWithClaims(token, &AuthorizationTokenClaims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
|
|
@ -99,10 +179,58 @@ func TokenToTaskID(token string) (int64, error) {
|
|||
return 0, err
|
||||
}
|
||||
|
||||
c, ok := parsedToken.Claims.(*actionsClaims)
|
||||
c, ok := parsedToken.Claims.(*AuthorizationTokenClaims)
|
||||
if !parsedToken.Valid || !ok {
|
||||
return 0, errors.New("invalid token claim")
|
||||
}
|
||||
|
||||
return c.TaskID, nil
|
||||
}
|
||||
|
||||
func ParseAuthorizationTokenClaims(req *http.Request) (*AuthorizationTokenClaims, error) {
|
||||
token, err := parseTokenFromHeader(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claims, err := decodeTokenClaims(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func parseTokenFromHeader(req *http.Request) (string, error) {
|
||||
h := req.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(h, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
log.Error("split token failed: %s", h)
|
||||
return "", errors.New("split token failed")
|
||||
}
|
||||
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
func decodeTokenClaims(token string) (*AuthorizationTokenClaims, error) {
|
||||
parsedToken, err := jwt.ParseWithClaims(token, &AuthorizationTokenClaims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return setting.GetGeneralTokenSigningSecret(), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c, ok := parsedToken.Claims.(*AuthorizationTokenClaims)
|
||||
if !parsedToken.Valid || !ok {
|
||||
return nil, errors.New("invalid token claim")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net/http"
|
||||
"testing"
|
||||
|
||||
actions_model "forgejo.org/models/actions"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/setting"
|
||||
|
||||
|
|
@ -16,34 +17,102 @@ import (
|
|||
)
|
||||
|
||||
func TestCreateAuthorizationToken(t *testing.T) {
|
||||
var taskID int64 = 23
|
||||
token, err := CreateAuthorizationToken(taskID, 1, 2)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
claims := jwt.MapClaims{}
|
||||
_, err = jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||
return setting.GetGeneralTokenSigningSecret(), nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
scp, ok := claims["scp"]
|
||||
assert.True(t, ok, "Has scp claim in jwt token")
|
||||
assert.Contains(t, scp, "Actions.Results:1:2")
|
||||
taskIDClaim, ok := claims["TaskID"]
|
||||
assert.True(t, ok, "Has TaskID claim in jwt token")
|
||||
assert.InDelta(t, float64(taskID), taskIDClaim, 0, "Supplied taskid must match stored one")
|
||||
acClaim, ok := claims["ac"]
|
||||
assert.True(t, ok, "Has ac claim in jwt token")
|
||||
ac, ok := acClaim.(string)
|
||||
assert.True(t, ok, "ac claim is a string for buildx gha cache")
|
||||
scopes := []actionsCacheScope{}
|
||||
err = json.Unmarshal([]byte(ac), &scopes)
|
||||
require.NoError(t, err, "ac claim is a json list for buildx gha cache")
|
||||
assert.GreaterOrEqual(t, len(scopes), 1, "Expected at least one action cache scope for buildx gha cache")
|
||||
task := &actions_model.ActionTask{
|
||||
ID: 23,
|
||||
Job: &actions_model.ActionRunJob{
|
||||
ID: 2,
|
||||
RunID: 1,
|
||||
},
|
||||
}
|
||||
|
||||
testcases := []struct {
|
||||
name string
|
||||
enableOpenIDConnect bool
|
||||
gitCtx map[string]any
|
||||
}{
|
||||
{
|
||||
name: "enableOpenIDConnect false",
|
||||
enableOpenIDConnect: false,
|
||||
gitCtx: map[string]any{},
|
||||
},
|
||||
{
|
||||
name: "enableOpenIDConnect true",
|
||||
enableOpenIDConnect: true,
|
||||
gitCtx: map[string]any{
|
||||
"actor": "user1",
|
||||
"base_ref": "master",
|
||||
"event_name": "push",
|
||||
"head_ref": "master",
|
||||
"ref": "refs/heads/master",
|
||||
"ref_protected": "false",
|
||||
"ref_type": "branch",
|
||||
"repository": "mpminardi/testing",
|
||||
"repository_owner": "mpminardi",
|
||||
"run_attempt": "1",
|
||||
"run_id": "1",
|
||||
"run_number": "1",
|
||||
"sha": "pretend-sha",
|
||||
"workflow": "test.yml",
|
||||
"workflow_ref": "pretend-ref",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testcases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
token, err := CreateAuthorizationToken(task, tc.gitCtx, tc.enableOpenIDConnect)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
claims := jwt.MapClaims{}
|
||||
_, err = jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||
return setting.GetGeneralTokenSigningSecret(), nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
scp, ok := claims["scp"]
|
||||
assert.True(t, ok, "Has scp claim in jwt token")
|
||||
assert.Contains(t, scp, "Actions.Results:1:2")
|
||||
taskIDClaim, ok := claims["TaskID"]
|
||||
assert.True(t, ok, "Has TaskID claim in jwt token")
|
||||
assert.InDelta(t, float64(task.ID), taskIDClaim, 0, "Supplied taskid must match stored one")
|
||||
acClaim, ok := claims["ac"]
|
||||
assert.True(t, ok, "Has ac claim in jwt token")
|
||||
ac, ok := acClaim.(string)
|
||||
assert.True(t, ok, "ac claim is a string for buildx gha cache")
|
||||
scopes := []actionsCacheScope{}
|
||||
err = json.Unmarshal([]byte(ac), &scopes)
|
||||
require.NoError(t, err, "ac claim is a json list for buildx gha cache")
|
||||
assert.GreaterOrEqual(t, len(scopes), 1, "Expected at least one action cache scope for buildx gha cache")
|
||||
|
||||
if tc.enableOpenIDConnect {
|
||||
assert.Contains(t, scp, "generate_id_token:1:2")
|
||||
oidcSubClaim, ok := claims["oidc_sub"]
|
||||
assert.True(t, ok, "Has oidc_sub claim in jwt token")
|
||||
assert.Equal(t, "repo:mpminardi/testing:ref:refs/heads/master", oidcSubClaim)
|
||||
oidcExtraClaim, ok := claims["oidc_extra"]
|
||||
assert.True(t, ok, "Has oidc_extra claim in jwt token")
|
||||
val, err := json.Marshal(tc.gitCtx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(val), oidcExtraClaim)
|
||||
} else {
|
||||
assert.NotContains(t, scp, "generate_id_token")
|
||||
_, ok := claims["oidc_sub"]
|
||||
assert.False(t, ok, "Does not have oidc_sub claim in jwt token")
|
||||
_, ok = claims["oidc_extra"]
|
||||
assert.False(t, ok, "Does not have oidc_extra claim in jwt token")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuthorizationToken(t *testing.T) {
|
||||
var taskID int64 = 23
|
||||
token, err := CreateAuthorizationToken(taskID, 1, 2)
|
||||
task := &actions_model.ActionTask{
|
||||
ID: 23,
|
||||
Job: &actions_model.ActionRunJob{
|
||||
ID: 2,
|
||||
RunID: 1,
|
||||
},
|
||||
}
|
||||
token, err := CreateAuthorizationToken(task, map[string]any{}, false)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
headers := http.Header{}
|
||||
|
|
@ -52,7 +121,51 @@ func TestParseAuthorizationToken(t *testing.T) {
|
|||
Header: headers,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, taskID, rTaskID)
|
||||
assert.Equal(t, task.ID, rTaskID)
|
||||
}
|
||||
|
||||
func TestParseAuthorizationTokenClaims(t *testing.T) {
|
||||
task := &actions_model.ActionTask{
|
||||
ID: 23,
|
||||
Job: &actions_model.ActionRunJob{
|
||||
ID: 2,
|
||||
RunID: 1,
|
||||
},
|
||||
}
|
||||
gitCtx := map[string]any{
|
||||
"actor": "user1",
|
||||
"base_ref": "master",
|
||||
"event_name": "push",
|
||||
"head_ref": "master",
|
||||
"ref": "refs/heads/master",
|
||||
"ref_protected": "false",
|
||||
"ref_type": "branch",
|
||||
"repository": "mpminardi/testing",
|
||||
"repository_owner": "mpminardi",
|
||||
"run_attempt": "1",
|
||||
"run_id": "1",
|
||||
"run_number": "1",
|
||||
"sha": "pretend-sha",
|
||||
"workflow": "test.yml",
|
||||
"workflow_ref": "pretend-ref",
|
||||
}
|
||||
token, err := CreateAuthorizationToken(task, gitCtx, true)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
headers := http.Header{}
|
||||
headers.Set("Authorization", "Bearer "+token)
|
||||
tokenClaims, err := ParseAuthorizationTokenClaims(&http.Request{
|
||||
Header: headers,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, task.ID, tokenClaims.TaskID)
|
||||
assert.Equal(t, task.Job.ID, tokenClaims.JobID)
|
||||
assert.Equal(t, task.Job.RunID, tokenClaims.RunID)
|
||||
var customClaims map[string]any
|
||||
err = json.Unmarshal([]byte(tokenClaims.OIDCExtra), &customClaims)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gitCtx, customClaims)
|
||||
assert.Equal(t, "repo:mpminardi/testing:ref:refs/heads/master", tokenClaims.OIDCSub)
|
||||
}
|
||||
|
||||
func TestParseAuthorizationTokenNoAuthHeader(t *testing.T) {
|
||||
|
|
@ -63,3 +176,25 @@ func TestParseAuthorizationTokenNoAuthHeader(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), rTaskID)
|
||||
}
|
||||
|
||||
func TestGenerateOIDCSub(t *testing.T) {
|
||||
t.Run("pull_request event", func(t *testing.T) {
|
||||
sub := generateOIDCSub(map[string]any{
|
||||
"event_name": "pull_request",
|
||||
"repository": "mpminardi/testing",
|
||||
"ref": "refs/heads/master",
|
||||
})
|
||||
|
||||
assert.Equal(t, "repo:mpminardi/testing:pull_request", sub)
|
||||
})
|
||||
|
||||
t.Run("other event", func(t *testing.T) {
|
||||
sub := generateOIDCSub(map[string]any{
|
||||
"event_name": "random",
|
||||
"repository": "mpminardi/testing",
|
||||
"ref": "refs/heads/master",
|
||||
})
|
||||
|
||||
assert.Equal(t, "repo:mpminardi/testing:ref:refs/heads/master", sub)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import (
|
|||
|
||||
actions_model "forgejo.org/models/actions"
|
||||
"forgejo.org/models/db"
|
||||
actions_module "forgejo.org/modules/actions"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/timeutil"
|
||||
"forgejo.org/modules/util"
|
||||
|
||||
|
|
@ -82,18 +84,39 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
|
|||
}
|
||||
|
||||
func generateTaskContext(t *actions_model.ActionTask) (*structpb.Struct, error) {
|
||||
giteaRuntimeToken, err := CreateAuthorizationToken(t.ID, t.Job.RunID, t.JobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gitCtx, err := GenerateGiteaContext(t.Job.Run, t.Job)
|
||||
run := t.Job.Run
|
||||
gitCtx, err := GenerateGiteaContext(run, t.Job)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gitCtx["token"] = t.Token
|
||||
|
||||
enableOpenIDConnect, err := t.Job.EnableOpenIDConnect()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Override the setting from the workflow is this is coming from a fork pull request
|
||||
// and this isn't a pull_request_target event.
|
||||
if run.IsForkPullRequest && run.TriggerEvent != actions_module.GithubEventPullRequestTarget {
|
||||
enableOpenIDConnect = false
|
||||
}
|
||||
|
||||
giteaRuntimeToken, err := CreateAuthorizationToken(t, gitCtx, enableOpenIDConnect)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gitCtx["gitea_runtime_token"] = giteaRuntimeToken
|
||||
|
||||
if enableOpenIDConnect {
|
||||
gitCtx["forgejo_actions_id_token_request_token"] = giteaRuntimeToken
|
||||
// The "placeholder=true" at the end of the URL is meaningless, but we need a param
|
||||
// here if we want to match the format used in GitHub actions examples (e.g., to ensure
|
||||
// that "ACTIONS_ID_TOKEN_REQUEST_URL&audience=..." will work as expected).
|
||||
gitCtx["forgejo_actions_id_token_request_url"] = setting.AppURL + setting.AppSubURL + fmt.Sprintf("api/actions/_apis/pipelines/workflows/%d/idtoken?placeholder=true", t.Job.RunID)
|
||||
}
|
||||
|
||||
return structpb.NewStruct(gitCtx)
|
||||
}
|
||||
|
||||
|
|
|
|||
99
services/actions/task_test.go
Normal file
99
services/actions/task_test.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
actions_model "forgejo.org/models/actions"
|
||||
"forgejo.org/models/repo"
|
||||
"forgejo.org/models/user"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateTaskContext(t *testing.T) {
|
||||
workflowFormat := `
|
||||
name: Pull Request
|
||||
on: pull_request
|
||||
enable-openid-connect: %s
|
||||
jobs:
|
||||
wf1-job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'test the pull'
|
||||
`
|
||||
testUser := &user.User{
|
||||
ID: 1,
|
||||
Name: "testuser",
|
||||
}
|
||||
|
||||
testRepo := &repo.Repository{
|
||||
ID: 1,
|
||||
OwnerName: "testowner",
|
||||
Name: "testrepo",
|
||||
}
|
||||
|
||||
createTask := func(workflowPayload string, isFork bool, triggerEvent string) *actions_model.ActionTask {
|
||||
return &actions_model.ActionTask{
|
||||
ID: 47,
|
||||
Job: &actions_model.ActionRunJob{
|
||||
ID: 2,
|
||||
RunID: 1,
|
||||
Run: &actions_model.ActionRun{
|
||||
ID: 1,
|
||||
Index: 42,
|
||||
TriggerUser: testUser,
|
||||
Repo: testRepo,
|
||||
TriggerEvent: triggerEvent,
|
||||
Ref: "refs/heads/main",
|
||||
CommitSHA: "abc123def456",
|
||||
WorkflowID: "test-workflow.yaml",
|
||||
WorkflowDirectory: ".forgejo/workflows",
|
||||
EventPayload: `{"repository": {"name": "testrepo"}}`,
|
||||
IsForkPullRequest: isFork,
|
||||
},
|
||||
WorkflowPayload: []byte(workflowPayload),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("openid connect enabled", func(t *testing.T) {
|
||||
task := createTask(fmt.Sprintf(workflowFormat, "true"), false, "push")
|
||||
|
||||
taskContext, err := generateTaskContext(task)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, taskContext.Fields["forgejo_actions_id_token_request_token"].GetStringValue())
|
||||
require.NotEmpty(t, taskContext.Fields["forgejo_actions_id_token_request_url"].GetStringValue())
|
||||
require.NotEmpty(t, taskContext.Fields["gitea_runtime_token"].GetStringValue())
|
||||
})
|
||||
|
||||
t.Run("openid connect enabled from fork with pull_request_target event", func(t *testing.T) {
|
||||
task := createTask(fmt.Sprintf(workflowFormat, "true"), true, "pull_request_target")
|
||||
|
||||
taskContext, err := generateTaskContext(task)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, taskContext.Fields["forgejo_actions_id_token_request_token"].GetStringValue())
|
||||
require.NotEmpty(t, taskContext.Fields["forgejo_actions_id_token_request_url"].GetStringValue())
|
||||
require.NotEmpty(t, taskContext.Fields["gitea_runtime_token"].GetStringValue())
|
||||
})
|
||||
|
||||
t.Run("openid connect enabled from fork with pull_request event", func(t *testing.T) {
|
||||
task := createTask(fmt.Sprintf(workflowFormat, "true"), true, "pull_request")
|
||||
|
||||
taskContext, err := generateTaskContext(task)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, taskContext.Fields["forgejo_actions_id_token_request_token"].GetStringValue())
|
||||
require.Empty(t, taskContext.Fields["forgejo_actions_id_token_request_url"].GetStringValue())
|
||||
require.NotEmpty(t, taskContext.Fields["gitea_runtime_token"].GetStringValue())
|
||||
})
|
||||
|
||||
t.Run("openid connect disabled", func(t *testing.T) {
|
||||
task := createTask(fmt.Sprintf(workflowFormat, "false"), false, "push")
|
||||
|
||||
taskContext, err := generateTaskContext(task)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, taskContext.Fields["forgejo_actions_id_token_request_token"].GetStringValue())
|
||||
require.Empty(t, taskContext.Fields["forgejo_actions_id_token_request_url"].GetStringValue())
|
||||
require.NotEmpty(t, taskContext.Fields["gitea_runtime_token"].GetStringValue())
|
||||
})
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net/http"
|
||||
"testing"
|
||||
|
||||
actions_model "forgejo.org/models/actions"
|
||||
"forgejo.org/models/auth"
|
||||
"forgejo.org/models/unittest"
|
||||
user_model "forgejo.org/models/user"
|
||||
|
|
@ -22,7 +23,14 @@ func TestUserIDFromToken(t *testing.T) {
|
|||
|
||||
t.Run("Actions JWT", func(t *testing.T) {
|
||||
const RunningTaskID = 47
|
||||
token, err := actions.CreateAuthorizationToken(RunningTaskID, 1, 2)
|
||||
task := &actions_model.ActionTask{
|
||||
ID: RunningTaskID,
|
||||
Job: &actions_model.ActionRunJob{
|
||||
ID: 2,
|
||||
RunID: 1,
|
||||
},
|
||||
}
|
||||
token, err := actions.CreateAuthorizationToken(task, map[string]any{}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
ds := make(middleware.ContextData)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
"forgejo.org/models/auth"
|
||||
"forgejo.org/models/db"
|
||||
"forgejo.org/modules/jwtx"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/optional"
|
||||
"forgejo.org/modules/setting"
|
||||
|
|
@ -28,9 +29,14 @@ const UsersStoreKey = "gitea-oauth2-sessions"
|
|||
// ProviderHeaderKey is the HTTP header key
|
||||
const ProviderHeaderKey = "gitea-oauth2-provider"
|
||||
|
||||
// DefaultSigningKey is the default signing key for JWTs.
|
||||
var DefaultSigningKey jwtx.SigningKey
|
||||
|
||||
// Init initializes the oauth source
|
||||
func Init(ctx context.Context) error {
|
||||
if err := InitSigningKey(); err != nil {
|
||||
var err error
|
||||
DefaultSigningKey, err = jwtx.InitSigningKey(setting.GetGeneralTokenSigningSecret, setting.OAuth2.JWTSigningPrivateKeyFile, setting.OAuth2.JWTSigningAlgorithm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadOrCreateAsymmetricKey(t *testing.T) {
|
||||
loadKey := func(t *testing.T) any {
|
||||
t.Helper()
|
||||
loadOrCreateAsymmetricKey()
|
||||
|
||||
fileContent, err := os.ReadFile(setting.OAuth2.JWTSigningPrivateKeyFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
block, _ := pem.Decode(fileContent)
|
||||
assert.NotNil(t, block)
|
||||
assert.Equal(t, "PRIVATE KEY", block.Type)
|
||||
|
||||
parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
require.NoError(t, err)
|
||||
|
||||
return parsedKey
|
||||
}
|
||||
t.Run("RSA-2048", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.OAuth2.JWTSigningPrivateKeyFile, filepath.Join(t.TempDir(), "jwt-rsa-2048.priv"))()
|
||||
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "RS256")()
|
||||
|
||||
parsedKey := loadKey(t)
|
||||
|
||||
rsaPrivateKey := parsedKey.(*rsa.PrivateKey)
|
||||
assert.Equal(t, 2048, rsaPrivateKey.N.BitLen())
|
||||
|
||||
t.Run("Load key with differ specified algorithm", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "EdDSA")()
|
||||
|
||||
parsedKey := loadKey(t)
|
||||
rsaPrivateKey := parsedKey.(*rsa.PrivateKey)
|
||||
assert.Equal(t, 2048, rsaPrivateKey.N.BitLen())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("RSA-3072", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.OAuth2.JWTSigningPrivateKeyFile, filepath.Join(t.TempDir(), "jwt-rsa-3072.priv"))()
|
||||
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "RS384")()
|
||||
|
||||
parsedKey := loadKey(t)
|
||||
|
||||
rsaPrivateKey := parsedKey.(*rsa.PrivateKey)
|
||||
assert.Equal(t, 3072, rsaPrivateKey.N.BitLen())
|
||||
})
|
||||
|
||||
t.Run("RSA-4096", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.OAuth2.JWTSigningPrivateKeyFile, filepath.Join(t.TempDir(), "jwt-rsa-4096.priv"))()
|
||||
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "RS512")()
|
||||
|
||||
parsedKey := loadKey(t)
|
||||
|
||||
rsaPrivateKey := parsedKey.(*rsa.PrivateKey)
|
||||
assert.Equal(t, 4096, rsaPrivateKey.N.BitLen())
|
||||
})
|
||||
|
||||
t.Run("ECDSA-256", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.OAuth2.JWTSigningPrivateKeyFile, filepath.Join(t.TempDir(), "jwt-ecdsa-256.priv"))()
|
||||
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "ES256")()
|
||||
|
||||
parsedKey := loadKey(t)
|
||||
|
||||
ecdsaPrivateKey := parsedKey.(*ecdsa.PrivateKey)
|
||||
assert.Equal(t, 256, ecdsaPrivateKey.Params().BitSize)
|
||||
})
|
||||
|
||||
t.Run("ECDSA-384", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.OAuth2.JWTSigningPrivateKeyFile, filepath.Join(t.TempDir(), "jwt-ecdsa-384.priv"))()
|
||||
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "ES384")()
|
||||
|
||||
parsedKey := loadKey(t)
|
||||
|
||||
ecdsaPrivateKey := parsedKey.(*ecdsa.PrivateKey)
|
||||
assert.Equal(t, 384, ecdsaPrivateKey.Params().BitSize)
|
||||
})
|
||||
|
||||
t.Run("ECDSA-512", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.OAuth2.JWTSigningPrivateKeyFile, filepath.Join(t.TempDir(), "jwt-ecdsa-512.priv"))()
|
||||
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "ES512")()
|
||||
|
||||
parsedKey := loadKey(t)
|
||||
|
||||
ecdsaPrivateKey := parsedKey.(*ecdsa.PrivateKey)
|
||||
assert.Equal(t, 521, ecdsaPrivateKey.Params().BitSize)
|
||||
})
|
||||
|
||||
t.Run("EdDSA", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.OAuth2.JWTSigningPrivateKeyFile, filepath.Join(t.TempDir(), "jwt-eddsa.priv"))()
|
||||
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "EdDSA")()
|
||||
|
||||
parsedKey := loadKey(t)
|
||||
|
||||
assert.NotNil(t, parsedKey.(ed25519.PrivateKey))
|
||||
})
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
"forgejo.org/modules/jwtx"
|
||||
"forgejo.org/modules/timeutil"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
|
@ -41,7 +42,7 @@ type Token struct {
|
|||
}
|
||||
|
||||
// ParseToken parses a signed jwt string
|
||||
func ParseToken(jwtToken string, signingKey JWTSigningKey) (*Token, error) {
|
||||
func ParseToken(jwtToken string, signingKey jwtx.SigningKey) (*Token, error) {
|
||||
parsedToken, err := jwt.ParseWithClaims(jwtToken, &Token{}, func(token *jwt.Token) (any, error) {
|
||||
if token.Method == nil || token.Method.Alg() != signingKey.SigningMethod().Alg() {
|
||||
return nil, fmt.Errorf("unexpected signing algo: %v", token.Header["alg"])
|
||||
|
|
@ -63,7 +64,7 @@ func ParseToken(jwtToken string, signingKey JWTSigningKey) (*Token, error) {
|
|||
}
|
||||
|
||||
// SignToken signs the token with the JWT secret
|
||||
func (token *Token) SignToken(signingKey JWTSigningKey) (string, error) {
|
||||
func (token *Token) SignToken(signingKey jwtx.SigningKey) (string, error) {
|
||||
token.IssuedAt = jwt.NewNumericDate(time.Now())
|
||||
jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token)
|
||||
signingKey.PreProcessToken(jwtToken)
|
||||
|
|
@ -93,7 +94,7 @@ type OIDCToken struct {
|
|||
}
|
||||
|
||||
// SignToken signs an id_token with the (symmetric) client secret key
|
||||
func (token *OIDCToken) SignToken(signingKey JWTSigningKey) (string, error) {
|
||||
func (token *OIDCToken) SignToken(signingKey jwtx.SigningKey) (string, error) {
|
||||
token.IssuedAt = jwt.NewNumericDate(time.Now())
|
||||
jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token)
|
||||
signingKey.PreProcessToken(jwtToken)
|
||||
|
|
|
|||
|
|
@ -365,6 +365,41 @@ func TestActionsGiteaContext(t *testing.T) {
|
|||
t.Skip()
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
treePath string
|
||||
fileContent string
|
||||
enableOpenIDConnect bool
|
||||
}{
|
||||
{
|
||||
name: "openid_connect_disabled",
|
||||
treePath: ".gitea/workflows/pull.yml",
|
||||
fileContent: `name: Pull Request
|
||||
on: pull_request
|
||||
jobs:
|
||||
wf1-job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'test the pull'
|
||||
`,
|
||||
enableOpenIDConnect: false,
|
||||
},
|
||||
{
|
||||
name: "openid_connect_enabled",
|
||||
treePath: ".gitea/workflows/pull-enabled.yml",
|
||||
fileContent: `name: Pull Request
|
||||
on: pull_request
|
||||
jobs:
|
||||
wf1-job:
|
||||
enable-openid-connect: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'test the pull'
|
||||
`,
|
||||
enableOpenIDConnect: true,
|
||||
},
|
||||
}
|
||||
|
||||
onApplicationRun(t, func(t *testing.T, u *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user2Session := loginUser(t, user2.Name)
|
||||
|
|
@ -377,75 +412,79 @@ func TestActionsGiteaContext(t *testing.T) {
|
|||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-runner", []string{"ubuntu-latest"})
|
||||
|
||||
// init the workflow
|
||||
wfTreePath := ".gitea/workflows/pull.yml"
|
||||
wfFileContent := `name: Pull Request
|
||||
on: pull_request
|
||||
jobs:
|
||||
wf1-job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'test the pull'
|
||||
`
|
||||
opts := getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, fmt.Sprintf("create %s", wfTreePath), wfFileContent)
|
||||
createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, wfTreePath, opts)
|
||||
// user2 creates a pull request
|
||||
doAPICreateFile(user2APICtx, "user2-patch.txt", &api.CreateFileOptions{
|
||||
FileOptions: api.FileOptions{
|
||||
NewBranchName: "user2/patch-1",
|
||||
Message: "create user2-patch.txt",
|
||||
Author: api.Identity{
|
||||
Name: user2.Name,
|
||||
Email: user2.Email,
|
||||
},
|
||||
Committer: api.Identity{
|
||||
Name: user2.Name,
|
||||
Email: user2.Email,
|
||||
},
|
||||
Dates: api.CommitDateOptions{
|
||||
Author: time.Now(),
|
||||
Committer: time.Now(),
|
||||
},
|
||||
},
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("user2-fix")),
|
||||
})(t)
|
||||
apiPull, err := doAPICreatePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, "user2/patch-1")(t)
|
||||
require.NoError(t, err)
|
||||
task := runner.fetchTask(t)
|
||||
gtCtx := task.Context.GetFields()
|
||||
actionTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
|
||||
actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: actionTask.JobID})
|
||||
actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: actionRunJob.RunID})
|
||||
require.NoError(t, actionRun.LoadAttributes(t.Context()))
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
opts := getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, fmt.Sprintf("create %s", tc.treePath), tc.fileContent)
|
||||
createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, tc.treePath, opts)
|
||||
// user2 creates a pull request
|
||||
doAPICreateFile(user2APICtx, "user2-patch.txt", &api.CreateFileOptions{
|
||||
FileOptions: api.FileOptions{
|
||||
NewBranchName: tc.name,
|
||||
Message: "create user2-patch.txt",
|
||||
Author: api.Identity{
|
||||
Name: user2.Name,
|
||||
Email: user2.Email,
|
||||
},
|
||||
Committer: api.Identity{
|
||||
Name: user2.Name,
|
||||
Email: user2.Email,
|
||||
},
|
||||
Dates: api.CommitDateOptions{
|
||||
Author: time.Now(),
|
||||
Committer: time.Now(),
|
||||
},
|
||||
},
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte("user2-fix")),
|
||||
})(t)
|
||||
apiPull, err := doAPICreatePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, tc.name)(t)
|
||||
require.NoError(t, err)
|
||||
task := runner.fetchTask(t)
|
||||
gtCtx := task.Context.GetFields()
|
||||
actionTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
|
||||
actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: actionTask.JobID})
|
||||
actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: actionRunJob.RunID})
|
||||
require.NoError(t, actionRun.LoadAttributes(t.Context()))
|
||||
|
||||
assert.Equal(t, user2.Name, gtCtx["actor"].GetStringValue())
|
||||
assert.Equal(t, setting.AppURL+"api/v1", gtCtx["api_url"].GetStringValue())
|
||||
assert.Equal(t, apiPull.Base.Ref, gtCtx["base_ref"].GetStringValue())
|
||||
runEvent := map[string]any{}
|
||||
require.NoError(t, json.Unmarshal([]byte(actionRun.EventPayload), &runEvent))
|
||||
assert.True(t, reflect.DeepEqual(gtCtx["event"].GetStructValue().AsMap(), runEvent))
|
||||
assert.Equal(t, actionRun.TriggerEvent, gtCtx["event_name"].GetStringValue())
|
||||
assert.Equal(t, apiPull.Head.Ref, gtCtx["head_ref"].GetStringValue())
|
||||
assert.Equal(t, actionRunJob.JobID, gtCtx["job"].GetStringValue())
|
||||
assert.Equal(t, actionRun.Ref, gtCtx["ref"].GetStringValue())
|
||||
assert.Equal(t, (git.RefName(actionRun.Ref)).ShortName(), gtCtx["ref_name"].GetStringValue())
|
||||
assert.False(t, gtCtx["ref_protected"].GetBoolValue())
|
||||
assert.Equal(t, (git.RefName(actionRun.Ref)).RefType(), gtCtx["ref_type"].GetStringValue())
|
||||
assert.Equal(t, actionRun.Repo.OwnerName+"/"+actionRun.Repo.Name, gtCtx["repository"].GetStringValue())
|
||||
assert.Equal(t, actionRun.Repo.OwnerName, gtCtx["repository_owner"].GetStringValue())
|
||||
assert.Equal(t, actionRun.Repo.HTMLURL(), gtCtx["repositoryUrl"].GetStringValue())
|
||||
assert.Equal(t, fmt.Sprint(actionRunJob.RunID), gtCtx["run_id"].GetStringValue())
|
||||
assert.Equal(t, fmt.Sprint(actionRun.Index), gtCtx["run_number"].GetStringValue())
|
||||
assert.Equal(t, fmt.Sprint(actionRunJob.Attempt), gtCtx["run_attempt"].GetStringValue())
|
||||
assert.Equal(t, "Actions", gtCtx["secret_source"].GetStringValue())
|
||||
assert.Equal(t, setting.AppURL, gtCtx["server_url"].GetStringValue())
|
||||
assert.Equal(t, actionRun.CommitSHA, gtCtx["sha"].GetStringValue())
|
||||
assert.Equal(t, actionRun.WorkflowID, gtCtx["workflow"].GetStringValue())
|
||||
assert.Equal(t, "user2/actions-gitea-context/.gitea/workflows/pull.yml@refs/pull/1/head", gtCtx["workflow_ref"].GetStringValue())
|
||||
assert.Equal(t, setting.Actions.DefaultActionsURL.URL(), gtCtx["gitea_default_actions_url"].GetStringValue())
|
||||
assert.Equal(t, setting.AppVer, gtCtx["forgejo_server_version"].GetStringValue())
|
||||
token := gtCtx["token"].GetStringValue()
|
||||
assert.Equal(t, actionTask.TokenLastEight, token[len(token)-8:])
|
||||
assert.Equal(t, user2.Name, gtCtx["actor"].GetStringValue())
|
||||
assert.Equal(t, setting.AppURL+"api/v1", gtCtx["api_url"].GetStringValue())
|
||||
assert.Equal(t, apiPull.Base.Ref, gtCtx["base_ref"].GetStringValue())
|
||||
runEvent := map[string]any{}
|
||||
require.NoError(t, json.Unmarshal([]byte(actionRun.EventPayload), &runEvent))
|
||||
assert.True(t, reflect.DeepEqual(gtCtx["event"].GetStructValue().AsMap(), runEvent))
|
||||
assert.Equal(t, actionRun.TriggerEvent, gtCtx["event_name"].GetStringValue())
|
||||
assert.Equal(t, apiPull.Head.Ref, gtCtx["head_ref"].GetStringValue())
|
||||
assert.Equal(t, actionRunJob.JobID, gtCtx["job"].GetStringValue())
|
||||
assert.Equal(t, actionRun.Ref, gtCtx["ref"].GetStringValue())
|
||||
assert.Equal(t, (git.RefName(actionRun.Ref)).ShortName(), gtCtx["ref_name"].GetStringValue())
|
||||
assert.False(t, gtCtx["ref_protected"].GetBoolValue())
|
||||
assert.Equal(t, (git.RefName(actionRun.Ref)).RefType(), gtCtx["ref_type"].GetStringValue())
|
||||
assert.Equal(t, actionRun.Repo.OwnerName+"/"+actionRun.Repo.Name, gtCtx["repository"].GetStringValue())
|
||||
assert.Equal(t, actionRun.Repo.OwnerName, gtCtx["repository_owner"].GetStringValue())
|
||||
assert.Equal(t, actionRun.Repo.HTMLURL(), gtCtx["repositoryUrl"].GetStringValue())
|
||||
assert.Equal(t, fmt.Sprint(actionRunJob.RunID), gtCtx["run_id"].GetStringValue())
|
||||
assert.Equal(t, fmt.Sprint(actionRun.Index), gtCtx["run_number"].GetStringValue())
|
||||
assert.Equal(t, fmt.Sprint(actionRunJob.Attempt), gtCtx["run_attempt"].GetStringValue())
|
||||
assert.Equal(t, "Actions", gtCtx["secret_source"].GetStringValue())
|
||||
assert.Equal(t, setting.AppURL, gtCtx["server_url"].GetStringValue())
|
||||
assert.Equal(t, actionRun.CommitSHA, gtCtx["sha"].GetStringValue())
|
||||
assert.Equal(t, actionRun.WorkflowID, gtCtx["workflow"].GetStringValue())
|
||||
assert.Contains(t, gtCtx["workflow_ref"].GetStringValue(), fmt.Sprintf("user2/actions-gitea-context/%s@refs/pull", tc.treePath))
|
||||
assert.Equal(t, setting.Actions.DefaultActionsURL.URL(), gtCtx["gitea_default_actions_url"].GetStringValue())
|
||||
assert.Equal(t, setting.AppVer, gtCtx["forgejo_server_version"].GetStringValue())
|
||||
token := gtCtx["token"].GetStringValue()
|
||||
assert.Equal(t, actionTask.TokenLastEight, token[len(token)-8:])
|
||||
if tc.enableOpenIDConnect {
|
||||
assert.NotEmpty(t, gtCtx["forgejo_actions_id_token_request_token"].GetStringValue())
|
||||
assert.Equal(t,
|
||||
fmt.Sprintf("%sapi/actions/_apis/pipelines/workflows/%d/idtoken?placeholder=true",
|
||||
setting.AppURL, actionRunJob.RunID), gtCtx["forgejo_actions_id_token_request_url"].GetStringValue(),
|
||||
)
|
||||
} else {
|
||||
assert.Empty(t, gtCtx["forgejo_actions_id_token_request_token"].GetStringValue())
|
||||
assert.Empty(t, gtCtx["forgejo_actions_id_token_request_url"].GetStringValue())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
doAPIDeleteRepository(user2APICtx)(t)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
actions_model "forgejo.org/models/actions"
|
||||
"forgejo.org/modules/storage"
|
||||
"forgejo.org/routers/api/actions"
|
||||
actions_service "forgejo.org/services/actions"
|
||||
|
|
@ -35,7 +36,14 @@ func toProtoJSON(m protoreflect.ProtoMessage) io.Reader {
|
|||
}
|
||||
|
||||
func uploadArtifact(t *testing.T, body string) string {
|
||||
token, err := actions_service.CreateAuthorizationToken(48, 792, 193)
|
||||
task := &actions_model.ActionTask{
|
||||
ID: 48,
|
||||
Job: &actions_model.ActionRunJob{
|
||||
ID: 193,
|
||||
RunID: 792,
|
||||
},
|
||||
}
|
||||
token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// acquire artifact upload url
|
||||
|
|
@ -88,7 +96,14 @@ func TestActionsArtifactV4UploadSingleFile(t *testing.T) {
|
|||
func TestActionsArtifactV4UploadSingleFileWrongChecksum(t *testing.T) {
|
||||
defer prepareTestEnvActionsArtifacts(t)()
|
||||
|
||||
token, err := actions_service.CreateAuthorizationToken(48, 792, 193)
|
||||
task := &actions_model.ActionTask{
|
||||
ID: 48,
|
||||
Job: &actions_model.ActionRunJob{
|
||||
ID: 193,
|
||||
RunID: 792,
|
||||
},
|
||||
}
|
||||
token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// acquire artifact upload url
|
||||
|
|
@ -132,7 +147,14 @@ func TestActionsArtifactV4UploadSingleFileWrongChecksum(t *testing.T) {
|
|||
func TestActionsArtifactV4UploadSingleFileWithRetentionDays(t *testing.T) {
|
||||
defer prepareTestEnvActionsArtifacts(t)()
|
||||
|
||||
token, err := actions_service.CreateAuthorizationToken(48, 792, 193)
|
||||
task := &actions_model.ActionTask{
|
||||
ID: 48,
|
||||
Job: &actions_model.ActionRunJob{
|
||||
ID: 193,
|
||||
RunID: 792,
|
||||
},
|
||||
}
|
||||
token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// acquire artifact upload url
|
||||
|
|
@ -180,7 +202,14 @@ func TestActionsArtifactV4UploadSingleFileWithRetentionDays(t *testing.T) {
|
|||
func TestActionsArtifactV4UploadSingleFileWithPotentialHarmfulBlockID(t *testing.T) {
|
||||
defer prepareTestEnvActionsArtifacts(t)()
|
||||
|
||||
token, err := actions_service.CreateAuthorizationToken(48, 792, 193)
|
||||
task := &actions_model.ActionTask{
|
||||
ID: 48,
|
||||
Job: &actions_model.ActionRunJob{
|
||||
ID: 193,
|
||||
RunID: 792,
|
||||
},
|
||||
}
|
||||
token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// acquire artifact upload url
|
||||
|
|
@ -243,7 +272,14 @@ func TestActionsArtifactV4UploadSingleFileWithPotentialHarmfulBlockID(t *testing
|
|||
func TestActionsArtifactV4UploadSingleFileWithChunksOutOfOrder(t *testing.T) {
|
||||
defer prepareTestEnvActionsArtifacts(t)()
|
||||
|
||||
token, err := actions_service.CreateAuthorizationToken(48, 792, 193)
|
||||
task := &actions_model.ActionTask{
|
||||
ID: 48,
|
||||
Job: &actions_model.ActionRunJob{
|
||||
ID: 193,
|
||||
RunID: 792,
|
||||
},
|
||||
}
|
||||
token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// acquire artifact upload url
|
||||
|
|
@ -308,7 +344,14 @@ func TestActionsArtifactV4UploadSingleFileWithChunksOutOfOrder(t *testing.T) {
|
|||
func TestActionsArtifactV4DownloadSingle(t *testing.T) {
|
||||
defer prepareTestEnvActionsArtifacts(t)()
|
||||
|
||||
token, err := actions_service.CreateAuthorizationToken(48, 792, 193)
|
||||
task := &actions_model.ActionTask{
|
||||
ID: 48,
|
||||
Job: &actions_model.ActionRunJob{
|
||||
ID: 193,
|
||||
RunID: 792,
|
||||
},
|
||||
}
|
||||
token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// acquire artifact upload url
|
||||
|
|
@ -369,7 +412,14 @@ func TestActionsArtifactV4DownloadRange(t *testing.T) {
|
|||
func TestActionsArtifactV4Delete(t *testing.T) {
|
||||
defer prepareTestEnvActionsArtifacts(t)()
|
||||
|
||||
token, err := actions_service.CreateAuthorizationToken(48, 792, 193)
|
||||
task := &actions_model.ActionTask{
|
||||
ID: 48,
|
||||
Job: &actions_model.ActionRunJob{
|
||||
ID: 193,
|
||||
RunID: 792,
|
||||
},
|
||||
}
|
||||
token, err := actions_service.CreateAuthorizationToken(task, map[string]any{}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// delete artifact by name
|
||||
|
|
|
|||
182
tests/integration/api_actions_id_token_test.go
Normal file
182
tests/integration/api_actions_id_token_test.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
actions_model "forgejo.org/models/actions"
|
||||
"forgejo.org/models/db"
|
||||
"forgejo.org/modules/setting"
|
||||
actions_service "forgejo.org/services/actions"
|
||||
"forgejo.org/tests"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type getTokenResponse struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func prepareTestEnvActionsIDToken(t *testing.T) func() {
|
||||
t.Helper()
|
||||
f := tests.PrepareTestEnv(t, 1)
|
||||
return f
|
||||
}
|
||||
|
||||
func TestActionsIDToken(t *testing.T) {
|
||||
defer prepareTestEnvActionsIDToken(t)()
|
||||
task, err := actions_model.GetTaskByID(db.DefaultContext, 48)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = task.LoadAttributes(db.DefaultContext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gitCtx, err := actions_service.GenerateGiteaContext(task.Job.Run, task.Job)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := actions_service.CreateAuthorizationToken(task, gitCtx, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// get JWKs information
|
||||
req := NewRequest(t, "GET", "/api/actions/.well-known/keys")
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var jwks jwksResponse
|
||||
DecodeJSON(t, resp, &jwks)
|
||||
require.Len(t, jwks["keys"], 1)
|
||||
key := jwks["keys"][0]
|
||||
|
||||
var exponent []byte
|
||||
if exponent, err = base64.RawURLEncoding.DecodeString(key["e"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var modulus []byte
|
||||
if modulus, err = base64.RawURLEncoding.DecodeString(key["n"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pubKey := rsa.PublicKey{
|
||||
E: int(big.NewInt(0).SetBytes(exponent).Uint64()),
|
||||
N: big.NewInt(0).SetBytes(modulus),
|
||||
}
|
||||
|
||||
t.Run("success path", func(t *testing.T) {
|
||||
doAssertions := func(aud string, claims map[string]any) {
|
||||
assert.Equal(t, "user1", claims["actor"])
|
||||
assert.Equal(t, aud, claims["aud"])
|
||||
assert.Equal(t, setting.AppURL+"api/actions", claims["iss"])
|
||||
assert.Equal(t, "refs/heads/master", claims["ref"])
|
||||
assert.Equal(t, "false", claims["ref_protected"])
|
||||
assert.Equal(t, "branch", claims["ref_type"])
|
||||
assert.Equal(t, "user5/repo4", claims["repository"])
|
||||
assert.Equal(t, "user5", claims["repository_owner"])
|
||||
assert.Equal(t, "1", claims["run_attempt"])
|
||||
assert.Equal(t, "792", claims["run_id"])
|
||||
assert.Equal(t, "188", claims["run_number"])
|
||||
assert.Equal(t, "c2d72f548424103f01ee1dc02889c1e2bff816b0", claims["sha"])
|
||||
assert.Equal(t, "repo:user5/repo4:ref:refs/heads/master", claims["sub"])
|
||||
assert.Equal(t, "artifact.yaml", claims["workflow"])
|
||||
assert.Equal(t, "user5/repo4/.forgejo/workflows/artifact.yaml@refs/heads/master", claims["workflow_ref"])
|
||||
}
|
||||
|
||||
// Default aud
|
||||
req = NewRequest(t, "GET", "/api/actions/_apis/pipelines/workflows/792/idtoken?placeholder=true").AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
var getResponse getTokenResponse
|
||||
DecodeJSON(t, resp, &getResponse)
|
||||
|
||||
claims := jwt.MapClaims{}
|
||||
_, err = jwt.ParseWithClaims(getResponse.Value, claims, func(t *jwt.Token) (any, error) {
|
||||
return &pubKey, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
doAssertions(setting.AppURL+"user5", claims)
|
||||
|
||||
// Custom aud
|
||||
req = NewRequest(t, "GET", "/api/actions/_apis/pipelines/workflows/792/idtoken?placeholder=true&audience=testingAud").AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &getResponse)
|
||||
|
||||
claims = jwt.MapClaims{}
|
||||
_, err = jwt.ParseWithClaims(getResponse.Value, claims, func(t *jwt.Token) (any, error) {
|
||||
return &pubKey, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
doAssertions("testingAud", claims)
|
||||
})
|
||||
|
||||
t.Run("with no auth header", func(t *testing.T) {
|
||||
req = NewRequest(t, "GET", "/api/actions/_apis/pipelines/workflows/792/idtoken?placeholder=true&audience=testingAud")
|
||||
resp = MakeRequest(t, req, http.StatusUnauthorized)
|
||||
assert.Contains(t, resp.Body.String(), "Bad authorization header")
|
||||
})
|
||||
|
||||
t.Run("with bad token format", func(t *testing.T) {
|
||||
req = NewRequest(t, "GET", "/api/actions/_apis/pipelines/workflows/792/idtoken?placeholder=true&audience=testingAud").AddTokenAuth("1234567")
|
||||
resp = MakeRequest(t, req, http.StatusInternalServerError)
|
||||
assert.Contains(t, resp.Body.String(), "Error runner api parsing authorization token")
|
||||
})
|
||||
|
||||
t.Run("with invalid task", func(t *testing.T) {
|
||||
task, err := actions_model.GetTaskByID(db.DefaultContext, 48)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = task.LoadAttributes(db.DefaultContext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Change ID to be invalid
|
||||
task.ID = 123456
|
||||
|
||||
gitCtx, err := actions_service.GenerateGiteaContext(task.Job.Run, task.Job)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := actions_service.CreateAuthorizationToken(task, gitCtx, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
req = NewRequest(t, "GET", "/api/actions/_apis/pipelines/workflows/abcde/idtoken?placeholder=true&audience=testingAud").AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusInternalServerError)
|
||||
assert.Contains(t, resp.Body.String(), "Error runner api getting task by ID")
|
||||
})
|
||||
|
||||
t.Run("with task that is not running", func(t *testing.T) {
|
||||
task, err := actions_model.GetTaskByID(db.DefaultContext, 49)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = task.LoadAttributes(db.DefaultContext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gitCtx, err := actions_service.GenerateGiteaContext(task.Job.Run, task.Job)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := actions_service.CreateAuthorizationToken(task, gitCtx, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
req = NewRequest(t, "GET", "/api/actions/_apis/pipelines/workflows/abcde/idtoken?placeholder=true&audience=testingAud").AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusInternalServerError)
|
||||
assert.Contains(t, resp.Body.String(), "Error runner api getting task: task is not running")
|
||||
})
|
||||
|
||||
t.Run("with mismatched run ID", func(t *testing.T) {
|
||||
req = NewRequest(t, "GET", "/api/actions/_apis/pipelines/workflows/123/idtoken?placeholder=true&audience=testingAud").AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusBadRequest)
|
||||
assert.Contains(t, resp.Body.String(), "run-id does not match")
|
||||
})
|
||||
}
|
||||
71
tests/integration/api_actions_oidc_test.go
Normal file
71
tests/integration/api_actions_oidc_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type jwksResponse map[string][]map[string]string
|
||||
|
||||
type openIDConfigurationResponse struct {
|
||||
Issuer string `json:"issuer"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
}
|
||||
|
||||
func prepareTestEnvActionsOIDC(t *testing.T) func() {
|
||||
t.Helper()
|
||||
f := tests.PrepareTestEnv(t, 1)
|
||||
return f
|
||||
}
|
||||
|
||||
func TestActionsOIDC(t *testing.T) {
|
||||
defer prepareTestEnvActionsOIDC(t)()
|
||||
|
||||
// get config information
|
||||
req := NewRequest(t, "GET", "/api/actions/.well-known/openid-configuration")
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var config openIDConfigurationResponse
|
||||
DecodeJSON(t, resp, &config)
|
||||
assert.Equal(t, setting.AppURL+"api/actions", config.Issuer)
|
||||
assert.Equal(t, setting.AppURL+"api/actions/.well-known/keys", config.JwksURI)
|
||||
assert.Equal(t, []string{"public"}, config.SubjectTypesSupported)
|
||||
assert.Equal(t, []string{"id_token"}, config.ResponseTypesSupported)
|
||||
assert.Equal(t, []string{"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"}, config.ClaimsSupported)
|
||||
assert.Equal(t, []string{"RS256"}, config.IDTokenSigningAlgValuesSupported)
|
||||
assert.Equal(t, []string{"openid"}, config.ScopesSupported)
|
||||
|
||||
// get JWKs information
|
||||
req = NewRequest(t, "GET", config.JwksURI)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
var jwks jwksResponse
|
||||
DecodeJSON(t, resp, &jwks)
|
||||
require.Len(t, jwks["keys"], 1)
|
||||
key := jwks["keys"][0]
|
||||
require.Equal(t, "RSA", key["kty"])
|
||||
require.Equal(t, "RS256", key["alg"])
|
||||
require.Equal(t, "sig", key["use"])
|
||||
|
||||
// Basic validation of returned exponents
|
||||
if _, err := base64.RawURLEncoding.DecodeString(key["e"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := base64.RawURLEncoding.DecodeString(key["n"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue