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:
Mario Minardi 2026-01-15 03:39:00 +01:00 committed by Mathieu Fenniak
commit c84cbd56a1
24 changed files with 1473 additions and 308 deletions

View file

@ -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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View file

@ -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
}

View file

@ -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
}

View 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))
})
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View 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
View 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)
}

View file

@ -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)
}

View file

@ -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{

View file

@ -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)

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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)
}

View 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())
})
}

View file

@ -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)

View file

@ -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
}

View file

@ -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))
})
}

View file

@ -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)

View file

@ -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)
})

View file

@ -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

View 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")
})
}

View 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)
}
}