From c84cbd56a1152dfc0e41f25812acdb3a7954c769 Mon Sep 17 00:00:00 2001 From: Mario Minardi Date: Thu, 15 Jan 2026 03:39:00 +0100 Subject: [PATCH] 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 ## 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/.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 Co-authored-by: Mario Minardi Co-committed-by: Mario Minardi --- custom/conf/app.example.ini | 9 + models/actions/run_job.go | 9 + .../jwtx/signingkey.go | 139 +++++++------ modules/jwtx/signingkey_test.go | 113 +++++++++++ modules/setting/actions.go | 24 +++ modules/setting/actions_test.go | 61 ++++++ routers/api/actions/actions.go | 3 + routers/api/actions/id_token.go | 151 ++++++++++++++ routers/api/actions/oidc.go | 146 ++++++++++++++ routers/init.go | 2 + routers/web/auth/oauth.go | 9 +- routers/web/auth/oauth_test.go | 3 +- services/actions/auth.go | 168 ++++++++++++++-- services/actions/auth_test.go | 187 +++++++++++++++--- services/actions/task.go | 35 +++- services/actions/task_test.go | 99 ++++++++++ services/auth/oauth2_test.go | 10 +- services/auth/source/oauth2/init.go | 8 +- .../auth/source/oauth2/jwtsigningkey_test.go | 116 ----------- services/auth/source/oauth2/token.go | 7 +- tests/integration/actions_job_test.go | 175 +++++++++------- .../api_actions_artifact_v4_test.go | 64 +++++- .../integration/api_actions_id_token_test.go | 182 +++++++++++++++++ tests/integration/api_actions_oidc_test.go | 71 +++++++ 24 files changed, 1478 insertions(+), 313 deletions(-) rename services/auth/source/oauth2/jwtsigningkey.go => modules/jwtx/signingkey.go (77%) create mode 100644 modules/jwtx/signingkey_test.go create mode 100644 routers/api/actions/id_token.go create mode 100644 routers/api/actions/oidc.go create mode 100644 services/actions/task_test.go delete mode 100644 services/auth/source/oauth2/jwtsigningkey_test.go create mode 100644 tests/integration/api_actions_id_token_test.go create mode 100644 tests/integration/api_actions_oidc_test.go diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 20eb64f38a..051b65dd55 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -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 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/models/actions/run_job.go b/models/actions/run_job.go index 0e59b931b5..aee7cd9aa1 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -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 +} diff --git a/services/auth/source/oauth2/jwtsigningkey.go b/modules/jwtx/signingkey.go similarity index 77% rename from services/auth/source/oauth2/jwtsigningkey.go rename to modules/jwtx/signingkey.go index 550945a812..14bbc8b2f5 100644 --- a/services/auth/source/oauth2/jwtsigningkey.go +++ b/modules/jwtx/signingkey.go @@ -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 +} diff --git a/modules/jwtx/signingkey_test.go b/modules/jwtx/signingkey_test.go new file mode 100644 index 0000000000..6f5cc3f49d --- /dev/null +++ b/modules/jwtx/signingkey_test.go @@ -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)) + }) +} diff --git a/modules/setting/actions.go b/modules/setting/actions.go index a0c8f977fa..2e0554195f 100644 --- a/modules/setting/actions.go +++ b/modules/setting/actions.go @@ -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 } diff --git a/modules/setting/actions_test.go b/modules/setting/actions_test.go index a3cd5ced44..8320b2057b 100644 --- a/modules/setting/actions_test.go +++ b/modules/setting/actions_test.go @@ -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) +} diff --git a/routers/api/actions/actions.go b/routers/api/actions/actions.go index 70158c4e18..73d3b90a8c 100644 --- a/routers/api/actions/actions.go +++ b/routers/api/actions/actions.go @@ -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 } diff --git a/routers/api/actions/id_token.go b/routers/api/actions/id_token.go new file mode 100644 index 0000000000..cd0432acb7 --- /dev/null +++ b/routers/api/actions/id_token.go @@ -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"` +} diff --git a/routers/api/actions/oidc.go b/routers/api/actions/oidc.go new file mode 100644 index 0000000000..92341e4f66 --- /dev/null +++ b/routers/api/actions/oidc.go @@ -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) +} diff --git a/routers/init.go b/routers/init.go index 6313d9a3ad..7c52a6d6b6 100644 --- a/routers/init.go +++ b/routers/init.go @@ -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) } diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index f44a102a49..739d36ddde 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -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{ diff --git a/routers/web/auth/oauth_test.go b/routers/web/auth/oauth_test.go index 9782711dd0..cc2020fcda 100644 --- a/routers/web/auth/oauth_test.go +++ b/routers/web/auth/oauth_test.go @@ -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) diff --git a/services/actions/auth.go b/services/actions/auth.go index 98b618aeba..c147643504 100644 --- a/services/actions/auth.go +++ b/services/actions/auth.go @@ -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 +} diff --git a/services/actions/auth_test.go b/services/actions/auth_test.go index d9f0437e1b..82d4d2efd3 100644 --- a/services/actions/auth_test.go +++ b/services/actions/auth_test.go @@ -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) + }) +} diff --git a/services/actions/task.go b/services/actions/task.go index 1de054346a..7b4e32571d 100644 --- a/services/actions/task.go +++ b/services/actions/task.go @@ -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) } diff --git a/services/actions/task_test.go b/services/actions/task_test.go new file mode 100644 index 0000000000..f725a93674 --- /dev/null +++ b/services/actions/task_test.go @@ -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()) + }) +} diff --git a/services/auth/oauth2_test.go b/services/auth/oauth2_test.go index a8fe9961fa..67e42c3c56 100644 --- a/services/auth/oauth2_test.go +++ b/services/auth/oauth2_test.go @@ -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) diff --git a/services/auth/source/oauth2/init.go b/services/auth/source/oauth2/init.go index 6c78a14da4..ac7853222c 100644 --- a/services/auth/source/oauth2/init.go +++ b/services/auth/source/oauth2/init.go @@ -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 } diff --git a/services/auth/source/oauth2/jwtsigningkey_test.go b/services/auth/source/oauth2/jwtsigningkey_test.go deleted file mode 100644 index 9b07b022df..0000000000 --- a/services/auth/source/oauth2/jwtsigningkey_test.go +++ /dev/null @@ -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)) - }) -} diff --git a/services/auth/source/oauth2/token.go b/services/auth/source/oauth2/token.go index b060b6b746..6b5c3676aa 100644 --- a/services/auth/source/oauth2/token.go +++ b/services/auth/source/oauth2/token.go @@ -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) diff --git a/tests/integration/actions_job_test.go b/tests/integration/actions_job_test.go index e91f0e92bd..91c61fe258 100644 --- a/tests/integration/actions_job_test.go +++ b/tests/integration/actions_job_test.go @@ -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) }) diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 02324ee73b..62be33d552 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -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 diff --git a/tests/integration/api_actions_id_token_test.go b/tests/integration/api_actions_id_token_test.go new file mode 100644 index 0000000000..d5c7301ad1 --- /dev/null +++ b/tests/integration/api_actions_id_token_test.go @@ -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") + }) +} diff --git a/tests/integration/api_actions_oidc_test.go b/tests/integration/api_actions_oidc_test.go new file mode 100644 index 0000000000..71e96d7531 --- /dev/null +++ b/tests/integration/api_actions_oidc_test.go @@ -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) + } +}