Files
Max P. a4908d4af4 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>
2026-10-18 07:08:55 +00:00

286 lines
9.5 KiB
Go

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