enhance(actions): add OIDC workload identity support (#39052)

Adds OpenID Connect workload identity to Gitea Actions: jobs declaring
`id-token: write` can request short-lived tokens to authenticate to
cloud providers and other services without stored secrets. Tokens follow
GitHub's format, so clients like `@actions/core` and existing trust
policies carry over.

- Issuer at `/api/actions/oidc` with discovery and JWKS, signed with a
dedicated RS256 key
- GitHub's claims, including `workflow_ref`, `job_workflow_ref` and the
immutable `repo:owner@id/repo@id:ref:...` subject
- `id-token: write` or `write-all` grant it, default permissions and
fork pull requests never get it, owner and repository maximum
permissions can disable it
- Reusable workflows record the exact ref they were loaded from, and
dispatch rejects refs that only resolve through a same-named tag
- Generated signing keys are written atomically, and generated ES384 and
ES512 OAuth2 keys use their own curves instead of P-256
- `github.ref_type` is `branch` for pull request runs, like on GitHub

Fixes https://github.com/go-gitea/gitea/issues/26383
Fixes https://github.com/go-gitea/gitea/issues/33681
Docs: https://gitea.com/gitea/docs/pulls/526
Assisted-by: ChatGPT:gpt-5.6-sol, Claude Code:claude-opus-5-5
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Max P.
2026-10-18 07:08:55 +00:00
committed by GitHub
parent 31626de2a6
commit a4908d4af4
28 changed files with 717 additions and 87 deletions
+1
View File
@@ -438,6 +438,7 @@ func prepareMigrationTasks() []*migration {
newMigration(359, "Add stage and number columns to action_task_step", v29.AddStageAndNumberToActionTaskStep),
newMigration(360, "Move branch protection settings to JSON", v29.MoveBranchProtectionConfigToJSON),
newMigration(361, "Drop legacy commit_id column from comment", v29.DropCommentCommitID),
newMigration(362, "Add reusable_workflow_ref column to action_run_job", v29.AddReusableWorkflowRefToActionRunJob),
}
return preparedMigrations
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v29
import (
"context"
"gitea.dev/modelmigration/base"
"xorm.io/xorm"
)
func AddReusableWorkflowRefToActionRunJob(_ context.Context, x base.EngineMigration) error {
type ActionRunJob struct {
ReusableWorkflowRef string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"`
}
_, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreConstrains: true,
IgnoreDropIndices: true,
}, new(ActionRunJob))
return err
}
+1 -1
View File
@@ -64,7 +64,7 @@ func (cfg *OwnerActionsConfig) GetMaxTokenPermissions() repo_model.ActionsTokenP
return *cfg.MaxTokenPermissions
}
// Default max is write for everything
return repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite)
return repo_model.MakeWriteAllActionsTokenPermissions()
}
// ClampPermissions ensures that the given permissions don't exceed the maximum
+2
View File
@@ -110,6 +110,8 @@ type ActionRunJob struct {
// ReusableWorkflowContent is the content of the reusable workflow specified by "uses:".
// Only set when IsReusableCaller is true.
ReusableWorkflowContent []byte `xorm:"LONGBLOB"`
// ReusableWorkflowRef is the fully qualified ref, or the commit, that "uses:" resolved to when the content was loaded.
ReusableWorkflowRef string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"`
// CallSecrets encodes the reusable workflow caller's "secrets:" section:
// - "" : no "secrets:" section (children only see auto-generated tokens).
// - "inherit" : the caller wrote "secrets: inherit".
+9 -1
View File
@@ -29,6 +29,7 @@ func (ActionsTokenPermissionMode) EnumValues() []ActionsTokenPermissionMode {
// ActionsTokenPermissions defines the permissions for different repository units
type ActionsTokenPermissions struct {
UnitAccessModes map[unit.Type]perm.AccessMode `json:"unit_access_modes,omitempty"`
IDToken bool `json:"id_token,omitempty"` // default modes never grant it, like on GitHub
}
var ActionsTokenUnitTypes = []unit.Type{
@@ -50,12 +51,19 @@ func MakeActionsTokenPermissions(unitAccessMode perm.AccessMode) (ret ActionsTok
return ret
}
func MakeWriteAllActionsTokenPermissions() ActionsTokenPermissions {
ret := MakeActionsTokenPermissions(perm.AccessModeWrite)
ret.IDToken = true
return ret
}
// ClampActionsTokenPermissions ensures that the given permissions don't exceed the maximum
func ClampActionsTokenPermissions(p1, p2 ActionsTokenPermissions) (ret ActionsTokenPermissions) {
ret.UnitAccessModes = make(map[unit.Type]perm.AccessMode)
for _, ut := range ActionsTokenUnitTypes {
ret.UnitAccessModes[ut] = min(p1.UnitAccessModes[ut], p2.UnitAccessModes[ut])
}
ret.IDToken = p1.IDToken && p2.IDToken
return ret
}
@@ -156,7 +164,7 @@ func (cfg *ActionsConfig) GetMaxTokenPermissions() ActionsTokenPermissions {
return *cfg.MaxTokenPermissions
}
// Default max is write for everything
return MakeActionsTokenPermissions(perm.AccessModeWrite)
return MakeWriteAllActionsTokenPermissions()
}
// ClampPermissions ensures that the given permissions don't exceed the maximum
+4
View File
@@ -57,6 +57,8 @@ func TestActionsConfigTokenPermissions(t *testing.T) {
assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeCode])
assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeIssues])
assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypePackages])
assert.False(t, perms.IDToken)
assert.True(t, cfg.GetMaxTokenPermissions().IDToken)
})
t.Run("Effective Permissions - Restricted Mode", func(t *testing.T) {
@@ -92,6 +94,7 @@ func TestActionsConfigTokenPermissions(t *testing.T) {
unit.TypeActions: perm.AccessModeRead, // Should be clamped to None
unit.TypeWiki: perm.AccessModeRead, // Should stay Read
},
IDToken: true,
}
clamped := cfg.ClampPermissions(input)
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeCode])
@@ -100,5 +103,6 @@ func TestActionsConfigTokenPermissions(t *testing.T) {
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypePackages])
assert.Equal(t, perm.AccessModeNone, clamped.UnitAccessModes[unit.TypeActions])
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeWiki])
assert.False(t, clamped.IDToken)
})
}
+2
View File
@@ -4002,6 +4002,8 @@
"actions.general.token_permissions.maximum.description": "Actions job's effective permissions will be limited by the maximum permissions.",
"actions.general.token_permissions.fork_pr_note": "If a job is started by a pull request from a fork, its effective permissions won't exceed the read-only permissions.",
"actions.general.token_permissions.customize_max_permissions": "Customize maximum permissions",
"actions.general.token_permissions.id_token": "OpenID Connect token",
"actions.general.token_permissions.id_token.description": "Allow jobs declaring \"id-token: write\" to request short-lived OpenID Connect tokens for authenticating to external services.",
"actions.general.cross_repo": "Cross-Repository Access",
"actions.general.cross_repo_desc": "Allow the selected repositories to be accessed (read-only) by all the repositories in this owner with GITEA_TOKEN when running Actions jobs.",
"actions.general.cross_repo_selected": "Selected repositories",
+5
View File
@@ -13,6 +13,11 @@ import (
func Routes(prefix string) *web.Router {
m := web.NewRouter()
m.Group("/oidc", func() {
m.Get("/.well-known/openid-configuration", oidcConfiguration)
m.Get("/jwks", oidcKeys)
m.Get("/token", oidcToken)
})
path, handler := ping.NewPingServiceHandler()
m.Post(path+"*", http.StripPrefix(prefix, handler).ServeHTTP)
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"errors"
"net/http"
"gitea.dev/modules/auth/httpauth"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
actions_service "gitea.dev/services/actions"
"gitea.dev/services/context"
)
func oidcConfiguration(resp http.ResponseWriter, req *http.Request) {
issuer := actions_service.OIDCIssuer()
context.NewBaseContext(resp, req).JSON(http.StatusOK, map[string]any{
"issuer": issuer,
"jwks_uri": issuer + "/jwks",
"response_types_supported": []string{"id_token"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{actions_service.OIDCSigningAlgorithm},
"claims_supported": actions_service.OIDCClaimsSupported(),
"scopes_supported": []string{"openid"},
})
}
func oidcKeys(resp http.ResponseWriter, req *http.Request) {
ctx := context.NewBaseContext(resp, req)
key, err := actions_service.OIDCSigningKey()
if err != nil {
log.Error("Actions OIDC signing key: %v", err)
ctx.HTTPError(http.StatusInternalServerError)
return
}
jwk, err := key.ToJWK()
if err != nil {
log.Error("Actions OIDC JWK: %v", err)
ctx.HTTPError(http.StatusInternalServerError)
return
}
jwk["use"] = "sig"
ctx.JSON(http.StatusOK, map[string]any{"keys": []map[string]string{jwk}})
}
func oidcToken(resp http.ResponseWriter, req *http.Request) {
ctx := context.NewBaseContext(resp, req)
token := ""
if auth, ok := httpauth.ParseAuthorizationHeader(req.Header.Get("Authorization")); ok && auth.BearerToken != nil {
token = auth.BearerToken.Token
}
idToken, err := actions_service.CreateOIDCToken(ctx, token, ctx.FormString("audience"))
if errors.Is(err, util.ErrPermissionDenied) {
ctx.Resp.Header().Set("WWW-Authenticate", "Bearer")
ctx.HTTPError(http.StatusUnauthorized)
return
} else if err != nil {
log.Error("Actions OIDC token: %v", err)
ctx.HTTPError(http.StatusInternalServerError)
return
}
ctx.Resp.Header().Set("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, map[string]string{"value": idToken})
}
+1
View File
@@ -39,6 +39,7 @@ func ParseMaxTokenPermissions(ctx *context.Context) *repo_model.ActionsTokenPerm
for _, ut := range repo_model.ActionsTokenUnitTypes {
ret.UnitAccessModes[ut] = parseMaxPerm(ut)
}
ret.IDToken = ctx.FormString("max_id_token") == "write"
return ret
}
+20 -16
View File
@@ -17,6 +17,8 @@ import (
"github.com/golang-jwt/jwt/v5"
)
const idTokenRequestScope = "Actions.IDTokenRequest" // separate from the runtime token, which runners also send to cache servers
type actionsClaims struct {
jwt.RegisteredClaims
Scp string `json:"scp"`
@@ -39,8 +41,6 @@ const (
)
func CreateAuthorizationToken(taskID, runID, jobID int64) (string, error) {
now := time.Now()
ac, err := json.Marshal(&[]actionsCacheScope{
{
Scope: "",
@@ -51,25 +51,22 @@ func CreateAuthorizationToken(taskID, runID, jobID int64) (string, error) {
return "", err
}
claims := actionsClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(1*time.Hour + setting.Actions.EndlessTaskTimeout)),
NotBefore: jwt.NewNumericDate(now),
},
return signActionsClaims(actionsClaims{
Scp: fmt.Sprintf("Actions.Results:%d:%d", runID, jobID),
Ac: string(ac),
TaskID: taskID,
RunID: runID,
JobID: jobID,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
})
}
tokenString, err := token.SignedString(setting.GetGeneralTokenSigningSecret())
if err != nil {
return "", err
func signActionsClaims(claims actionsClaims) (string, error) {
now := time.Now()
claims.RegisteredClaims = jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(1*time.Hour + setting.Actions.EndlessTaskTimeout)),
NotBefore: jwt.NewNumericDate(now),
}
return tokenString, nil
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(setting.GetGeneralTokenSigningSecret())
}
func ParseAuthorizationToken(req *http.Request) (int64, error) {
@@ -89,6 +86,10 @@ func ParseAuthorizationToken(req *http.Request) (int64, error) {
// TokenToTaskID returns the TaskID associated with the provided JWT token
func TokenToTaskID(token string) (int64, error) {
return taskIDFromToken(token, false)
}
func taskIDFromToken(token string, idTokenRequest bool) (int64, error) {
parsedToken, err := jwt.ParseWithClaims(token, &actionsClaims{}, 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 +100,13 @@ func TokenToTaskID(token string) (int64, error) {
return 0, err
}
c, ok := parsedToken.Claims.(*actionsClaims)
claims, ok := parsedToken.Claims.(*actionsClaims)
if !parsedToken.Valid || !ok {
return 0, errors.New("invalid token claim")
}
if (claims.Scp == idTokenRequestScope) != idTokenRequest {
return 0, errors.New("unexpected token scope")
}
return c.TaskID, nil
return claims.TaskID, nil
}
+5 -1
View File
@@ -58,6 +58,10 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att
}
refName := git.RefName(ref)
refType := refName.RefType()
if refName.IsPull() {
refType = git.RefTypeBranch // GitHub reports pull request refs as branches
}
refProtected, err := git_model.IsRefProtected(ctx, run.RepoID, refName)
if err != nil {
log.Error("GenerateGiteaContext: check protection for ref %q: %v", refName, err)
@@ -84,7 +88,7 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att
"ref": ref, // string, The fully-formed ref of the branch or tag that triggered the workflow run. For workflows triggered by push, this is the branch or tag ref that was pushed. For workflows triggered by pull_request, this is the pull request merge branch. For workflows triggered by release, this is the release tag created. For other triggers, this is the branch or tag ref that triggered the workflow run. This is only set if a branch or tag is available for the event type. The ref given is fully-formed, meaning that for branches the format is refs/heads/<branch_name>, for pull requests it is refs/pull/<pr_number>/merge, and for tags it is refs/tags/<tag_name>. For example, refs/heads/feature-branch-1.
"ref_name": refName.ShortName(), // string, The short ref name of the branch or tag that triggered the workflow run. This value matches the branch or tag name shown on GitHub. For example, feature-branch-1.
"ref_protected": refProtected, // boolean, true if protection rules are configured for the ref that triggered the workflow run.
"ref_type": string(refName.RefType()), // string, The type of ref that triggered the workflow run. Valid values are branch or tag.
"ref_type": string(refType), // string, The type of ref that triggered the workflow run. Valid values are branch or tag.
"path": "", // string, Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see "Workflow commands for GitHub Actions."
"repository": run.Repo.OwnerName + "/" + run.Repo.Name, // string, The owner and repository name. For example, Codertocat/Hello-World.
"repository_owner": run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat.
+4 -1
View File
@@ -385,7 +385,7 @@ func TestFindTaskNeeds(t *testing.T) {
assert.Equal(t, "bbb", ret["job1"].Outputs["output_b"])
}
func TestGenerateGiteaContextPullRequestTarget(t *testing.T) {
func TestGenerateGiteaContextPullRequestRefs(t *testing.T) {
payload := api.PullRequestPayload{
PullRequest: &api.PullRequest{
Base: &api.PRBranchInfo{
@@ -415,6 +415,9 @@ func TestGenerateGiteaContextPullRequestTarget(t *testing.T) {
assert.Equal(t, "refs/heads/main", giteaCtx["ref"])
assert.Equal(t, "main", giteaCtx["ref_name"])
run.TriggerEvent, run.Ref = string(actions_module.GithubEventPullRequest), "refs/pull/1/head"
assert.Equal(t, "branch", GenerateGiteaContext(t.Context(), run, nil, nil)["ref_type"])
}
func TestGenerateGiteaContextRefProtected(t *testing.T) {
+285
View File
@@ -0,0 +1,285 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"cmp"
"context"
"errors"
"fmt"
"net/url"
"path"
"path/filepath"
"reflect"
"strconv"
"strings"
"sync"
"time"
"uuid"
actions_model "gitea.dev/models/actions"
repo_model "gitea.dev/models/repo"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/git"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/services/oauth2_provider"
"github.com/golang-jwt/jwt/v5"
)
const OIDCSigningAlgorithm = "RS256"
var oidcSigningKey struct {
sync.Mutex
key oauth2_provider.JWTSigningKey
}
// OIDCSigningKey retries after errors, unlike sync.OnceValues, so a transient failure doesn't last until restart
func OIDCSigningKey() (oauth2_provider.JWTSigningKey, error) {
oidcSigningKey.Lock()
defer oidcSigningKey.Unlock()
if oidcSigningKey.key == nil {
key, err := oauth2_provider.LoadOrCreateAsymmetricKey(filepath.Join(setting.AppDataPath, "jwt", "actions_oidc.pem"), OIDCSigningAlgorithm)
if err != nil {
return nil, err
}
if oidcSigningKey.key, err = oauth2_provider.CreateJWTSigningKey(OIDCSigningAlgorithm, key); err != nil {
return nil, err
}
}
return oidcSigningKey.key, nil
}
type oidcClaims struct {
jwt.RegisteredClaims
Audience string `json:"aud"` // a single string like GitHub, jwt.ClaimStrings encodes an array
Actor string `json:"actor"`
ActorID string `json:"actor_id"`
Repository string `json:"repository"`
RepositoryID string `json:"repository_id"`
RepositoryOwner string `json:"repository_owner"`
RepositoryOwnerID string `json:"repository_owner_id"`
RepositoryVisibility string `json:"repository_visibility"`
RunID string `json:"run_id"`
RunNumber string `json:"run_number"`
RunAttempt string `json:"run_attempt"`
EventName string `json:"event_name"`
Ref string `json:"ref"`
RefType string `json:"ref_type"`
RefProtected string `json:"ref_protected"`
SHA string `json:"sha"`
BaseRef string `json:"base_ref"`
HeadRef string `json:"head_ref"`
Workflow string `json:"workflow"`
WorkflowRef string `json:"workflow_ref"`
WorkflowSHA string `json:"workflow_sha"`
JobWorkflowRef string `json:"job_workflow_ref"`
JobWorkflowSHA string `json:"job_workflow_sha"`
RunnerEnvironment string `json:"runner_environment"`
}
type workflowFile struct {
repo, path, ref, sha string
}
func (w workflowFile) String() string {
return w.repo + "/" + w.path + "@" + w.ref
}
func OIDCClaimsSupported() []string {
claims := []string{"exp", "iat", "iss", "jti", "nbf", "sub"}
for field := range reflect.TypeFor[oidcClaims]().Fields() {
if name, _, _ := strings.Cut(field.Tag.Get("json"), ","); name != "" {
claims = append(claims, name)
}
}
return claims
}
func OIDCIssuer() string {
return setting.AppURL + "api/actions/oidc"
}
func taskAllowsOIDCToken(ctx context.Context, task *actions_model.ActionTask) (bool, error) {
if task.Job.TokenPermissions == nil || !task.Job.TokenPermissions.IDToken {
return false, nil
}
task.Job.Repo = task.Job.Run.Repo
perms, err := actions_model.ComputeTaskTokenPermissions(ctx, task, task.Job.Repo)
return perms.IDToken, err
}
func CreateOIDCToken(ctx context.Context, requestToken, audience string) (string, error) {
taskID, err := taskIDFromToken(requestToken, true)
if err != nil {
return "", util.NewPermissionDeniedErrorf("invalid ID token request token")
}
task, err := actions_model.GetTaskByID(ctx, taskID)
if errors.Is(err, util.ErrNotExist) {
return "", util.NewPermissionDeniedErrorf("task %d does not exist", taskID)
} else if err != nil {
return "", err
}
if task.Status != actions_model.StatusRunning && task.Status != actions_model.StatusCancelling { // cleanup steps of cancelled jobs may still need one
return "", util.NewPermissionDeniedErrorf("task %d is not active", taskID)
}
if err := task.LoadJob(ctx); err != nil {
return "", err
}
if err := task.Job.LoadAttributes(ctx); err != nil {
return "", err
}
if allowed, err := taskAllowsOIDCToken(ctx, task); err != nil {
return "", err
} else if !allowed {
return "", util.NewPermissionDeniedErrorf("task %d lacks the id-token permission", taskID)
}
claims, err := createOIDCClaims(ctx, task.Job, audience, time.Now())
if err != nil {
return "", err
}
key, err := OIDCSigningKey()
if err != nil {
return "", err
}
token := jwt.NewWithClaims(key.SigningMethod(), claims)
key.PreProcessToken(token)
return token.SignedString(key.SignKey())
}
func createOIDCClaims(ctx context.Context, job *actions_model.ActionRunJob, audience string, now time.Time) (*oidcClaims, error) {
run := job.Run
gitCtx := GenerateGiteaContext(ctx, run, nil, job)
contextString := func(key string) string {
value, _ := gitCtx[key].(string)
return value
}
ref := contextString("ref")
rootWorkflow, workflowName, err := runWorkflowFile(ctx, run, ref)
if err != nil {
return nil, err
}
jobWorkflow, err := jobWorkflowFile(ctx, job, rootWorkflow)
if err != nil {
return nil, err
}
// GitHub's immutable format, the IDs keep a recreated owner or repo name from matching
subject := fmt.Sprintf("repo:%s@%d/%s@%d", run.Repo.OwnerName, run.Repo.OwnerID, run.Repo.Name, run.RepoID)
if run.TriggerEvent == actions_module.GithubEventPullRequest {
subject += ":pull_request"
} else {
subject += ":ref:" + ref
}
if audience == "" {
audience = setting.AppURL + url.PathEscape(run.Repo.OwnerName)
}
visibility := "internal"
if run.Repo.IsPrivate {
visibility = "private"
} else if run.Repo.Owner.Visibility.IsPublic() {
visibility = "public"
}
return &oidcClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: OIDCIssuer(),
Subject: subject,
ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
NotBefore: jwt.NewNumericDate(now.Add(-5 * time.Minute)), // backdated like GitHub for relying parties with skewed clocks
IssuedAt: jwt.NewNumericDate(now),
ID: uuid.New().String(),
},
Audience: audience,
Actor: contextString("actor"),
ActorID: contextString("actor_id"),
Repository: contextString("repository"),
RepositoryID: contextString("repository_id"),
RepositoryOwner: contextString("repository_owner"),
RepositoryOwnerID: contextString("repository_owner_id"),
RepositoryVisibility: visibility,
RunID: contextString("run_id"),
RunNumber: contextString("run_number"),
RunAttempt: contextString("run_attempt"),
EventName: contextString("event_name"),
Ref: ref,
RefType: contextString("ref_type"),
RefProtected: strconv.FormatBool(gitCtx["ref_protected"] == true),
SHA: contextString("sha"),
BaseRef: contextString("base_ref"),
HeadRef: contextString("head_ref"),
Workflow: workflowName,
WorkflowRef: rootWorkflow.String(),
WorkflowSHA: rootWorkflow.sha,
JobWorkflowRef: jobWorkflow.String(),
JobWorkflowSHA: jobWorkflow.sha,
RunnerEnvironment: "self-hosted",
}, nil
}
// runWorkflowFile repeats the directory choice workflow detection made at the recorded commit, and names the workflow like GitHub
func runWorkflowFile(ctx context.Context, run *actions_model.ActionRun, ref string) (file workflowFile, name string, err error) {
repo, err := repo_model.GetRepositoryByID(ctx, run.WorkflowRepoID)
if err != nil {
return file, "", err
}
gitRepo, err := git.OpenRepository(ctx, repo)
if err != nil {
return file, "", err
}
defer gitRepo.Close()
commit, err := gitRepo.GetCommit(ctx, run.WorkflowCommitSHA)
if err != nil {
return file, "", err
}
listWorkflows := actions_module.ListWorkflows
if run.IsScopedRun {
listWorkflows = actions_module.ListScopedWorkflows
ref = run.WorkflowCommitSHA // the source repo's branch is not recorded
}
dir, _, err := listWorkflows(ctx, gitRepo, commit)
if err != nil {
return file, "", err
}
file = workflowFile{repo: repo.FullName(), path: path.Join(dir, run.WorkflowID), ref: ref, sha: run.WorkflowCommitSHA}
content, err := commit.GetFileContent(ctx, gitRepo, file.path, 1024*1024)
if err != nil {
return file, "", err
}
if workflow, err := jobparser.ReadWorkflow([]byte(content)); err == nil && strings.TrimSpace(workflow.Name) != "" {
return file, workflow.Name, nil
}
return file, file.path, nil
}
// jobWorkflowFile lets local reusable workflow calls inherit their caller's ref, like on GitHub
func jobWorkflowFile(ctx context.Context, job *actions_model.ActionRunJob, root workflowFile) (workflowFile, error) {
if job.ParentJobID == 0 {
return root, nil
}
caller, err := actions_model.GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID)
if err != nil {
return workflowFile{}, err
}
uses, err := ResolveUses(ctx, caller.CallUses)
if err != nil {
return workflowFile{}, err
}
if uses.IsLocal() {
file, err := jobWorkflowFile(ctx, caller, root)
file.path, file.sha = uses.Path, job.WorkflowSourceCommitSHA
return file, err
}
repo, err := repo_model.GetRepositoryByID(ctx, job.WorkflowSourceRepoID)
if err != nil {
return workflowFile{}, err
}
ref := cmp.Or(caller.ReusableWorkflowRef, job.WorkflowSourceCommitSHA) // callers expanded before the ref was recorded
return workflowFile{repo: repo.FullName(), path: uses.Path, ref: ref, sha: job.WorkflowSourceCommitSHA}, nil
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestJobWorkflowFile_LocalCallsInheritCallerRefs(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
chain := buildCallerChain(t, "./.gitea/workflows/a.yml", "user2/repo1/.gitea/workflows/b.yml@master", "./.gitea/workflows/c.yml")
chain[1].ReusableWorkflowRef = "refs/heads/master"
for _, job := range chain[1:] {
job.WorkflowSourceRepoID, job.WorkflowSourceCommitSHA = 1, "lib-sha"
_, err := actions_model.UpdateRunJob(t.Context(), job, nil, "workflow_source_repo_id", "workflow_source_commit_sha", "reusable_workflow_ref")
require.NoError(t, err)
}
root := workflowFile{repo: "user2/repo1", ref: "refs/heads/main"}
file, err := jobWorkflowFile(t.Context(), chain[1], root)
require.NoError(t, err)
assert.Equal(t, "user2/repo1/.gitea/workflows/a.yml@refs/heads/main", file.String())
file, err = jobWorkflowFile(t.Context(), &actions_model.ActionRunJob{RunID: chain[2].RunID, ParentJobID: chain[2].ID, WorkflowSourceRepoID: 1, WorkflowSourceCommitSHA: "lib-sha"}, root)
require.NoError(t, err)
assert.Equal(t, workflowFile{repo: "user2/repo1", path: ".gitea/workflows/c.yml", ref: "refs/heads/master", sha: "lib-sha"}, file)
}
+4 -2
View File
@@ -59,7 +59,7 @@ func parseRawPermissionsExplicit(rawPerms *yaml.Node) *repo_model.ActionsTokenPe
case "read-all":
return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeRead))
case "write-all":
return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite))
return new(repo_model.MakeWriteAllActionsTokenPermissions())
default:
// Explicit but unrecognized scalar: return all-none permissions.
return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone))
@@ -113,10 +113,12 @@ func parseRawPermissionsExplicit(rawPerms *yaml.Node) *repo_model.ActionsTokenPe
result.UnitAccessModes[unit.TypeReleases] = mode
case "projects":
result.UnitAccessModes[unit.TypeProjects] = mode
case "id-token":
result.IDToken = mode == perm.AccessModeWrite
// Scopes github supports but gitea does not, see url for details
// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax
case "artifact-metadata", "attestations", "checks", "deployments",
"id-token", "models", "discussions", "pages", "security-events", "statuses":
"models", "discussions", "pages", "security-events", "statuses":
// not supported
default:
setting.PanicInDevOrTesting("Unrecognized permission scope: %s", scope)
@@ -78,6 +78,7 @@ func TestParseRawPermissions_WriteAll(t *testing.T) {
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeActions])
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeWiki])
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeProjects])
assert.True(t, result.IDToken)
}
func TestParseRawPermissions_IndividualScopes(t *testing.T) {
@@ -89,6 +90,7 @@ packages: write
actions: read
wiki: write
projects: none
id-token: write
`
var rawPerms yaml.Node
err := yaml.Unmarshal([]byte(yamlContent), &rawPerms)
@@ -104,6 +106,7 @@ projects: none
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeActions])
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeWiki])
assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeProjects])
assert.True(t, result.IDToken)
}
func TestParseRawPermissions_Priority(t *testing.T) {
+1
View File
@@ -561,6 +561,7 @@ func cloneRunJobForAttempt(templateJob *actions_model.ActionRunJob, attempt *act
IsReusableCaller: templateJob.IsReusableCaller,
CallUses: templateJob.CallUses,
ReusableWorkflowContent: slices.Clone(templateJob.ReusableWorkflowContent),
ReusableWorkflowRef: templateJob.ReusableWorkflowRef,
CallSecrets: templateJob.CallSecrets,
CallPayload: templateJob.CallPayload,
IsExpanded: templateJob.IsExpanded,
+53 -35
View File
@@ -51,11 +51,17 @@ func checkRunJobLimit(ctx context.Context, runID, attemptID int64, adding int) e
return nil
}
// loadReusableWorkflowSource resolves the workflow file referenced by a caller's `uses:` and returns its raw bytes,
// along with the (repo_id, commit_sha) the file was loaded from.
func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, ref *model.ReusableWorkflowUses) (content []byte, sourceRepoID int64, sourceCommitSHA string, err error) {
type reusableWorkflowSource struct {
content []byte
repoID int64
commitSHA string
ref string // fully qualified, or the commit when `uses:` named no exact branch or tag
}
// loadReusableWorkflowSource resolves the workflow file referenced by a caller's `uses:`.
func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, ref *model.ReusableWorkflowUses) (*reusableWorkflowSource, error) {
if err := run.LoadAttributes(ctx); err != nil {
return nil, 0, "", err
return nil, err
}
switch {
@@ -63,39 +69,31 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
// `./` and `$/` are resolved against the workflow file containing the `uses:` - i.e. the caller's own source repo + commit.
callerRepo, err := repo_model.GetRepositoryByID(ctx, caller.WorkflowSourceRepoID)
if err != nil {
return nil, 0, "", fmt.Errorf("look up caller source repo %d: %w", caller.WorkflowSourceRepoID, err)
return nil, fmt.Errorf("look up caller source repo %d: %w", caller.WorkflowSourceRepoID, err)
}
sourceCommitSHA := resolveSameRepoWorkflowSourceCommit(run, caller)
if sourceCommitSHA != caller.WorkflowSourceCommitSHA {
log.Warn("run %d (pull_request_target) records workflow source commit %s, resolving %q at base commit %s instead", run.ID, caller.WorkflowSourceCommitSHA, ref.Path, sourceCommitSHA)
}
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, callerRepo, sourceCommitSHA, ref.Path)
if err != nil {
return nil, 0, "", err
}
return bytes, callerRepo.ID, resolvedSHA, nil
return readWorkflowFromRepo(ctx, callerRepo, sourceCommitSHA, ref.Path)
default:
unavailable := fmt.Errorf("reusable workflow repository %s/%s does not exist or is not readable", ref.Owner, ref.Repo) // the same for both, so a run cannot tell whether a private one exists
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ref.Owner, ref.Repo)
if repo_model.IsErrRepoNotExist(err) {
return nil, 0, "", unavailable
return nil, unavailable
}
if err != nil {
return nil, 0, "", fmt.Errorf("look up cross-repo workflow source %q: %w", ref.Owner+"/"+ref.Repo, err)
return nil, fmt.Errorf("look up cross-repo workflow source %q: %w", ref.Owner+"/"+ref.Repo, err)
}
ok, err := access_model.CanReadWorkflowCrossRepo(ctx, repo, run)
if err != nil {
return nil, 0, "", err
return nil, err
}
if !ok {
return nil, 0, "", unavailable
return nil, unavailable
}
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, repo, ref.Ref, ref.Path)
if err != nil {
return nil, 0, "", err
}
return bytes, repo.ID, resolvedSHA, nil
return readWorkflowFromRepo(ctx, repo, ref.Ref, ref.Path)
}
}
@@ -123,12 +121,12 @@ func validateCalledWorkflows(ctx context.Context, run *actions_model.ActionRun,
if err != nil {
return fmt.Errorf("job %s: %w", id, err)
}
called, repoID, commitSHA, err := loadReusableWorkflowSource(ctx, run, source, ref)
called, err := loadReusableWorkflowSource(ctx, run, source, ref)
if err != nil {
return fmt.Errorf("job %s: %w", id, err)
}
if _, err = jobparser.ValidateWorkflowStatic(called); err == nil {
err = validate(called, &actions_model.ActionRunJob{WorkflowSourceRepoID: repoID, WorkflowSourceCommitSHA: commitSHA}, level+1)
if _, err = jobparser.ValidateWorkflowStatic(called.content); err == nil {
err = validate(called.content, &actions_model.ActionRunJob{WorkflowSourceRepoID: called.repoID, WorkflowSourceCommitSHA: called.commitSHA}, level+1)
}
if err != nil {
return fmt.Errorf("job %s: Error from called workflow %s: %w", id, uses, err)
@@ -156,23 +154,42 @@ func resolveSameRepoWorkflowSourceCommit(run *actions_model.ActionRun, caller *a
return caller.WorkflowSourceCommitSHA
}
// readWorkflowFromRepo loads a workflow file from `repo` at `refOrSHA` and returns its content plus the resolved commit SHA.
func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refOrSHA, path string) ([]byte, string, error) {
// readWorkflowFromRepo loads a workflow file from `repo` at `refOrSHA`.
func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refOrSHA, path string) (*reusableWorkflowSource, error) {
gitRepo, err := git.OpenRepository(ctx, repo)
if err != nil {
return nil, "", fmt.Errorf("open repo %s: %w", repo.FullName(), err)
return nil, fmt.Errorf("open repo %s: %w", repo.FullName(), err)
}
defer gitRepo.Close()
commit, err := gitRepo.GetCommit(ctx, refOrSHA)
if err != nil {
return nil, "", fmt.Errorf("get commit %q in %s: %w", refOrSHA, repo.FullName(), err)
return nil, fmt.Errorf("get commit %q in %s: %w", refOrSHA, repo.FullName(), err)
}
str, err := commit.GetFileContent(ctx, gitRepo, path, 1024*1024)
if err != nil {
return nil, "", fmt.Errorf("read %s@%s:%s: %w", repo.FullName(), refOrSHA, path, err)
return nil, fmt.Errorf("read %s@%s:%s: %w", repo.FullName(), refOrSHA, path, err)
}
return []byte(str), commit.ID.String(), nil
sha := commit.ID.String()
return &reusableWorkflowSource{content: []byte(str), repoID: repo.ID, commitSHA: sha, ref: qualifyRef(ctx, repo, gitRepo, refOrSHA, sha)}, nil
}
// qualifyRef names the exact ref `uses:` resolved to, tags first like Git, so a same-named tag can't pose as a branch
func qualifyRef(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, ref, sha string) string {
if ref == sha {
return sha
}
names := []string{git.TagPrefix + ref, git.BranchPrefix + ref}
if strings.HasPrefix(ref, "refs/") {
names = []string{ref}
}
for _, name := range names {
if git.IsReferenceExist(ctx, repo, name) { // GetCommit alone would also resolve a tag named like a missing branch
commit, err := gitRepo.GetCommit(ctx, name)
return util.Iif(err == nil && commit.ID.String() == sha, name, sha)
}
}
return sha
}
// checkCallerChain walks `caller`'s ancestor chain (via ParentJobID) and:
@@ -264,19 +281,19 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
if err != nil {
return fmt.Errorf("resolve uses %q: %w", parsedJob.Uses, err)
}
content, contentSourceRepoID, contentSourceCommitSHA, err := loadReusableWorkflowSource(ctx, run, caller, ref)
source, err := loadReusableWorkflowSource(ctx, run, caller, ref)
if err != nil {
return err
}
if err := checkResolvedCallerCycle(ctx, caller, contentSourceRepoID, contentSourceCommitSHA, ref.Path); err != nil {
if err := checkResolvedCallerCycle(ctx, caller, source.repoID, source.commitSHA, ref.Path); err != nil {
return err
}
if _, err := jobparser.ValidateWorkflowStatic(content); err != nil {
if _, err := jobparser.ValidateWorkflowStatic(source.content); err != nil {
return fmt.Errorf("invalid called workflow: %w", err)
}
// 4. Parse the called workflow's spec (used by both secret validation and input evaluation).
wcSpec, err := jobparser.ParseWorkflowCallConfig(content)
wcSpec, err := jobparser.ParseWorkflowCallConfig(source.content)
if err != nil {
return fmt.Errorf("parse called workflow spec: %w", err)
}
@@ -304,7 +321,8 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
}
caller.CallSecrets = string(mapBytes)
}
caller.ReusableWorkflowContent = content
caller.ReusableWorkflowContent = source.content
caller.ReusableWorkflowRef = source.ref
// 6. Evaluate caller's `with:`, then match against the callee schema.
workflowCallInputs := map[string]any{}
@@ -358,14 +376,14 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
}
// 9. We own the expansion: insert the direct children.
if err := insertCallerChildren(ctx, run, attempt, caller, content, contentSourceRepoID, contentSourceCommitSHA, vars, jobInputs); err != nil {
if err := insertCallerChildren(ctx, run, attempt, caller, source.content, source.repoID, source.commitSHA, vars, jobInputs); err != nil {
// On failure, undo the partial expansion so an error return always leaves the caller unexpanded and childless.
return errors.Join(err, undoExpansion(ctx, caller))
}
// 10. Persist the remaining caller metadata (the row is already ours via the claim above).
caller.CallPayload = string(callPayload)
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "call_secrets", "reusable_workflow_content", "call_payload"); err != nil {
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "call_secrets", "reusable_workflow_content", "reusable_workflow_ref", "call_payload"); err != nil {
return errors.Join(fmt.Errorf("persist caller %d expansion metadata: %w", caller.ID, err), undoExpansion(ctx, caller))
}
return nil
+18 -1
View File
@@ -10,8 +10,10 @@ import (
"gitea.dev/actionslib/pkg/model"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/git"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
@@ -265,7 +267,7 @@ func TestLoadReusableWorkflowSourceFailsAlikeForMissingAndPrivateRepo(t *testing
run := &actions_model.ActionRun{RepoID: 4, TriggerUserID: 1}
for _, repoName := range []string{"missing", "repo3"} {
_, _, _, err := loadReusableWorkflowSource(t.Context(), run, nil, &model.ReusableWorkflowUses{Owner: "org3", Repo: repoName, Path: ".gitea/workflows/build.yml", Ref: "main"})
_, err := loadReusableWorkflowSource(t.Context(), run, nil, &model.ReusableWorkflowUses{Owner: "org3", Repo: repoName, Path: ".gitea/workflows/build.yml", Ref: "main"})
assert.EqualError(t, err, "reusable workflow repository org3/"+repoName+" does not exist or is not readable")
}
}
@@ -412,3 +414,18 @@ func TestResolveSameRepoWorkflowSourceCommit(t *testing.T) {
assert.Equal(t, "tag-v1-sha", got)
})
}
func TestQualifyRef(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
gitRepo, err := git.OpenRepository(t.Context(), repo)
require.NoError(t, err)
defer gitRepo.Close()
masterSHA := "65f1bf27bc3bf70f64657658635e66094edbcb4d"
assert.Equal(t, "refs/heads/master", qualifyRef(t.Context(), repo, gitRepo, "master", masterSHA))
assert.Equal(t, "refs/heads/master", qualifyRef(t.Context(), repo, gitRepo, "refs/heads/master", masterSHA))
assert.Equal(t, "refs/tags/v1.1", qualifyRef(t.Context(), repo, gitRepo, "v1.1", masterSHA))
assert.Equal(t, masterSHA, qualifyRef(t.Context(), repo, gitRepo, masterSHA, masterSHA))
assert.Equal(t, masterSHA, qualifyRef(t.Context(), repo, gitRepo, "refs/heads/master~0", masterSHA))
assert.Equal(t, "other-sha", qualifyRef(t.Context(), repo, gitRepo, "master", "other-sha"))
}
+10
View File
@@ -200,6 +200,16 @@ func generateTaskContext(ctx context.Context, t *actions_model.ActionTask) (*str
}
gitCtx["token"] = t.Token
gitCtx["gitea_runtime_token"] = giteaRuntimeToken
if allowed, err := taskAllowsOIDCToken(ctx, t); err != nil {
return nil, err
} else if allowed {
requestToken, err := signActionsClaims(actionsClaims{Scp: idTokenRequestScope, TaskID: t.ID})
if err != nil {
return nil, err
}
gitCtx["actions_id_token_request_url"] = OIDCIssuer() + "/token?" // actions/core appends "&audience=..."
gitCtx["actions_id_token_request_token"] = requestToken
}
return structpb.NewStruct(gitCtx)
}
+3
View File
@@ -108,6 +108,9 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
refName = git.RefNameFromBranch(ref)
runTargetCommit, err = gitRepo.GetBranchCommit(ctx, ref)
}
if err == nil && !git.IsReferenceExist(ctx, repo, refName.String()) { // the lookups also resolve a tag named like a missing branch
err = util.ErrNotExist
}
if err != nil {
return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("ref %q doesn't exist", ref),
+22 -20
View File
@@ -12,13 +12,14 @@ import (
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io/fs"
"math/big"
"os"
"path/filepath"
"strings"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
@@ -321,7 +322,7 @@ func InitSigningKey() error {
case "ES512":
fallthrough
case "EdDSA":
key, err = loadOrCreateAsymmetricKey()
key, err = LoadOrCreateAsymmetricKey(setting.OAuth2.JWTSigningPrivateKeyFile, setting.OAuth2.JWTSigningAlgorithm)
default:
return ErrInvalidAlgorithmType{setting.OAuth2.JWTSigningAlgorithm}
}
@@ -340,24 +341,24 @@ func InitSigningKey() error {
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)
return nil, fmt.Errorf("unable to check if %s exists: %w", keyPath, err)
}
if !isExist {
err := func() error {
key, err := func() (any, error) {
switch {
case strings.HasPrefix(setting.OAuth2.JWTSigningAlgorithm, "RS"):
case strings.HasPrefix(algorithm, "RS"):
return rsa.GenerateKey(rand.Reader, 4096)
case setting.OAuth2.JWTSigningAlgorithm == "EdDSA":
case algorithm == "EdDSA":
_, pk, err := ed25519.GenerateKey(rand.Reader)
return pk, err
case algorithm == "ES384":
return ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
case algorithm == "ES512":
return ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
default:
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
}
@@ -377,21 +378,22 @@ func loadOrCreateAsymmetricKey() (any, error) {
return err
}
f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
f, err := os.CreateTemp(filepath.Dir(keyPath), filepath.Base(keyPath)+".*.tmp")
if err != nil {
return err
}
defer func() {
if err = f.Close(); err != nil {
log.Error("Close: %v", err)
}
}()
return pem.Encode(f, privateKeyPEM)
defer func() { _ = util.RemoveWithRetry(f.Name()) }()
if err := errors.Join(pem.Encode(f, privateKeyPEM), f.Close()); err != nil {
return err
}
// publish complete keys only, without replacing one another instance sharing the data path created first
if err := os.Link(f.Name(), keyPath); err != nil && !errors.Is(err, fs.ErrExist) {
return util.RenameWithRetry(f.Name(), keyPath) // filesystems without hard links
}
return nil
}()
if err != nil {
log.Fatal("Error generating private key: %v", err)
return nil, err
return nil, fmt.Errorf("error generating private key: %w", err)
}
}
@@ -5,10 +5,9 @@ package oauth2_provider
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"math/big"
"path/filepath"
"testing"
"github.com/golang-jwt/jwt/v5"
@@ -18,19 +17,20 @@ import (
func TestECDSASigningKeyToJWK(t *testing.T) {
for _, tc := range []struct {
curve elliptic.Curve
signingMethod jwt.SigningMethod
expectedAlg string
expectedCrv string
coordLen int
}{
{elliptic.P256(), jwt.SigningMethodES256, "ES256", "P-256", 32},
{elliptic.P384(), jwt.SigningMethodES384, "ES384", "P-384", 48},
{elliptic.P521(), jwt.SigningMethodES512, "ES512", "P-521", 66},
{jwt.SigningMethodES256, "ES256", "P-256", 32},
{jwt.SigningMethodES384, "ES384", "P-384", 48},
{jwt.SigningMethodES512, "ES512", "P-521", 66},
} {
t.Run(tc.expectedCrv, func(t *testing.T) {
privKey, err := ecdsa.GenerateKey(tc.curve, rand.Reader)
key, err := LoadOrCreateAsymmetricKey(filepath.Join(t.TempDir(), "key.pem"), tc.expectedAlg)
require.NoError(t, err)
privKey, ok := key.(*ecdsa.PrivateKey)
require.True(t, ok)
signingKey, err := newECDSASingingKey(tc.signingMethod, privKey)
require.NoError(t, err)
@@ -72,6 +72,25 @@
"UnitDisplayDesc" (ctx.Locale.Tr "actions.unit.desc")
"UnitAccessMode" (index $.MaxTokenPermissions.UnitAccessModes ctx.Consts.RepoUnitTypeActions)
)}}
<tr>
<td>
<strong>{{ctx.Locale.Tr "actions.general.token_permissions.id_token"}}</strong>
<div class="help">{{ctx.Locale.Tr "actions.general.token_permissions.id_token.description"}}</div>
</td>
<td class="tw-text-center">
<div class="ui radio checkbox">
<input type="radio" name="max_id_token" value="none" {{if not $.MaxTokenPermissions.IDToken}}checked{{end}} title="{{ctx.Locale.Tr "org.teams.none_access"}}">
<label></label>
</div>
</td>
<td></td>
<td class="tw-text-center">
<div class="ui radio checkbox">
<input type="radio" name="max_id_token" value="write" {{if $.MaxTokenPermissions.IDToken}}checked{{end}} title="{{ctx.Locale.Tr "org.teams.write_access"}}">
<label></label>
</div>
</td>
</tr>
</tbody>
</table>
</div>
+2 -2
View File
@@ -533,7 +533,7 @@ jobs:
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, string(git.RefName(actionRun.Ref).RefType()), gtCtx["ref_type"].GetStringValue())
assert.Equal(t, "branch", 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())
@@ -625,7 +625,7 @@ jobs:
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, string(git.RefName(actionRun.Ref).RefType()), gtCtx["ref_type"].GetStringValue())
assert.Equal(t, "branch", 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())
+105
View File
@@ -0,0 +1,105 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"crypto/rsa"
"encoding/base64"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"testing"
runnerv1 "gitea.dev/actionslib/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
actions_service "gitea.dev/services/actions"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsOIDC(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getTokenForLoggedInUser(t, loginUser(t, user2.Name), auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
repo := createActionsTestRepo(t, token, "actions-oidc", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, user2.Name, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
workflowPath := ".gitea/workflows/oidc.yml"
createWorkflowFile(t, token, user2.Name, repo.Name, workflowPath, getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+workflowPath, `name: deploy
on: push
jobs:
plain:
runs-on: ubuntu-latest
steps:
- run: echo
oidc:
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- run: echo
`))
tasks := map[string]*runnerv1.Task{}
for range 2 {
task := runner.fetchTask(t)
tasks[task.Context.GetFields()["job"].GetStringValue()] = task
}
assert.NotContains(t, tasks["plain"].Context.GetFields(), "actions_id_token_request_token")
taskContext := tasks["oidc"].Context.GetFields()
requestToken := taskContext["actions_id_token_request_token"].GetStringValue()
tokenURL, err := url.Parse(taskContext["actions_id_token_request_url"].GetStringValue() + "&audience=sts.example.com")
require.NoError(t, err)
requestIDToken := func(bearer string, status int) *httptest.ResponseRecorder {
return MakeRequest(t, NewRequest(t, "GET", tokenURL.RequestURI()).AddTokenAuth(bearer), status)
}
resp := MakeRequest(t, NewRequest(t, "GET", "/api/actions/oidc/.well-known/openid-configuration"), http.StatusOK)
discovery := DecodeJSON(t, resp, &struct {
JWKSURI string `json:"jwks_uri"`
ClaimsSupported []string `json:"claims_supported"`
}{})
assert.Contains(t, discovery.ClaimsSupported, "job_workflow_ref")
jwksURL, err := url.Parse(discovery.JWKSURI)
require.NoError(t, err)
resp = MakeRequest(t, NewRequest(t, "GET", jwksURL.RequestURI()), http.StatusOK)
jwk := DecodeJSON(t, resp, &struct{ Keys []map[string]string }{}).Keys[0]
require.Equal(t, "AQAB", jwk["e"])
modulus, err := base64.RawURLEncoding.DecodeString(jwk["n"])
require.NoError(t, err)
claims := jwt.MapClaims{}
idToken := DecodeJSON(t, requestIDToken(requestToken, http.StatusOK), &struct{ Value string }{}).Value
_, err = jwt.ParseWithClaims(idToken, claims, func(*jwt.Token) (any, error) {
return &rsa.PublicKey{N: new(big.Int).SetBytes(modulus), E: 65537}, nil
}, jwt.WithIssuer(actions_service.OIDCIssuer()), jwt.WithAudience("sts.example.com"))
require.NoError(t, err)
workflowRef := repo.FullName + "/" + workflowPath + "@refs/heads/" + repo.DefaultBranch
assert.Equal(t, fmt.Sprintf("repo:%s@%d/%s@%d:ref:refs/heads/%s", user2.Name, user2.ID, repo.Name, repo.ID, repo.DefaultBranch), claims["sub"])
assert.Equal(t, "sts.example.com", claims["aud"])
assert.Equal(t, "deploy", claims["workflow"])
assert.Equal(t, workflowRef, claims["workflow_ref"])
assert.Equal(t, workflowRef, claims["job_workflow_ref"])
assert.Equal(t, "public", claims["repository_visibility"])
requestIDToken(taskContext["gitea_runtime_token"].GetStringValue(), http.StatusUnauthorized)
_, job, _ := getTaskAndJobAndRunByTaskID(t, tasks["oidc"].Id)
job.TokenPermissions.IDToken = false
_, err = actions_model.UpdateRunJob(t.Context(), job, nil, "token_permissions")
require.NoError(t, err)
requestIDToken(requestToken, http.StatusUnauthorized)
job.TokenPermissions.IDToken = true
_, err = actions_model.UpdateRunJob(t.Context(), job, nil, "token_permissions")
require.NoError(t, err)
runner.execTask(t, tasks["oidc"], &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
requestIDToken(requestToken, http.StatusUnauthorized)
})
}
@@ -24,6 +24,7 @@ import (
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
@@ -992,6 +993,13 @@ jobs:
CommitSHA: branch.CommitID,
})
assert.NotNil(t, run)
_, _, err = gitcmd.NewCommand("update-ref").AddDynamicArguments("refs/tags/refs/heads/spoof", branch.CommitID).WithRepo(repo).RunStdString(t.Context())
require.NoError(t, err)
values.Set("ref", "spoof")
req = NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), values).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
}