mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-20 08:20:04 +00:00
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>
286 lines
9.5 KiB
Go
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
|
|
}
|