diff --git a/models/actions/config.go b/models/actions/config.go index 4f5357c560542..6c0c8d24e489a 100644 --- a/models/actions/config.go +++ b/models/actions/config.go @@ -64,7 +64,9 @@ func (cfg *OwnerActionsConfig) GetMaxTokenPermissions() repo_model.ActionsTokenP return *cfg.MaxTokenPermissions } // Default max is write for everything - return repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite) + ret := repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite) + ret.IDTokenAccessMode = perm.AccessModeWrite + return ret } // ClampPermissions ensures that the given permissions don't exceed the maximum diff --git a/models/repo/repo_unit_actions.go b/models/repo/repo_unit_actions.go index 50e2925792a31..fd7652517e2fe 100644 --- a/models/repo/repo_unit_actions.go +++ b/models/repo/repo_unit_actions.go @@ -28,7 +28,8 @@ 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"` + UnitAccessModes map[unit.Type]perm.AccessMode `json:"unit_access_modes,omitempty"` + IDTokenAccessMode perm.AccessMode `json:"id_token_access_mode,omitempty"` } var ActionsTokenUnitTypes = []unit.Type{ @@ -47,6 +48,7 @@ func MakeActionsTokenPermissions(unitAccessMode perm.AccessMode) (ret ActionsTok for _, u := range ActionsTokenUnitTypes { ret.UnitAccessModes[u] = unitAccessMode } + ret.IDTokenAccessMode = perm.AccessModeNone return ret } @@ -56,6 +58,7 @@ func ClampActionsTokenPermissions(p1, p2 ActionsTokenPermissions) (ret ActionsTo for _, ut := range ActionsTokenUnitTypes { ret.UnitAccessModes[ut] = min(p1.UnitAccessModes[ut], p2.UnitAccessModes[ut]) } + ret.IDTokenAccessMode = min(p1.IDTokenAccessMode, p2.IDTokenAccessMode) return ret } @@ -131,7 +134,9 @@ func (cfg *ActionsConfig) GetMaxTokenPermissions() ActionsTokenPermissions { return *cfg.MaxTokenPermissions } // Default max is write for everything - return MakeActionsTokenPermissions(perm.AccessModeWrite) + ret := MakeActionsTokenPermissions(perm.AccessModeWrite) + ret.IDTokenAccessMode = perm.AccessModeWrite + return ret } // ClampPermissions ensures that the given permissions don't exceed the maximum diff --git a/routers/api/actions/actions.go b/routers/api/actions/actions.go index 6f03f290ea535..a759992f726fc 100644 --- a/routers/api/actions/actions.go +++ b/routers/api/actions/actions.go @@ -13,6 +13,7 @@ import ( func Routes(prefix string) *web.Router { m := web.NewRouter() + registerOIDCRoutes(m) path, handler := ping.NewPingServiceHandler() m.Post(path+"*", http.StripPrefix(prefix, handler).ServeHTTP) diff --git a/routers/api/actions/oidc.go b/routers/api/actions/oidc.go new file mode 100644 index 0000000000000..c0dfa3144df19 --- /dev/null +++ b/routers/api/actions/oidc.go @@ -0,0 +1,198 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/oauth2_provider" +) + +func registerOIDCRoutes(m *web.Router) { + m.Group("/oidc", func() { + m.Get("/.well-known/openid-configuration", oidcWellKnown) + m.Get("/jwks", oidcKeys) + m.Get("/token", oidcToken) + }) +} + +func oidcWellKnown(resp http.ResponseWriter, req *http.Request) { + ctx := context.NewBaseContext(resp, req) + if !setting.OAuth2.Enabled { + ctx.HTTPError(http.StatusNotFound) + return + } + issuer := actions_service.OIDCIssuer() + signingKey := oauth2_provider.DefaultSigningKey + if signingKey == nil { + ctx.HTTPError(http.StatusInternalServerError, "OIDC signing key is not initialized") + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "issuer": issuer, + "jwks_uri": issuer + "/jwks", + "token_endpoint": issuer + "/token", + "response_types_supported": []string{"id_token"}, + "subject_types_supported": []string{"public"}, + "id_token_signing_alg_values_supported": []string{signingKey.SigningMethod().Alg()}, + "claims_supported": []string{ + "aud", + "exp", + "iat", + "iss", + "jti", + "sub", + "nbf", + "actor", + "actor_id", + "repository", + "repository_id", + "repository_owner", + "repository_owner_id", + "run_id", + "run_number", + "run_attempt", + "workflow", + "workflow_ref", + "workflow_sha", + "job_workflow_ref", + "job_workflow_sha", + "repository_visibility", + "event_name", + "ref", + "ref_type", + "sha", + "job_id", + "job_attempt", + "base_ref", + "head_ref", + "runner_environment", + "environment", + }, + }) +} + +func oidcKeys(resp http.ResponseWriter, req *http.Request) { + ctx := context.NewBaseContext(resp, req) + if !setting.OAuth2.Enabled { + ctx.HTTPError(http.StatusNotFound) + return + } + signingKey := oauth2_provider.DefaultSigningKey + if signingKey == nil { + ctx.HTTPError(http.StatusInternalServerError, "OIDC signing key is not initialized") + return + } + + jwk, err := signingKey.ToJWK() + if err != nil { + log.Error("Error converting signing key to JWK: %v", err) + ctx.HTTPError(http.StatusInternalServerError) + return + } + + jwk["use"] = "sig" + ctx.Resp.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(ctx.Resp).Encode(map[string][]map[string]string{"keys": {jwk}}); err != nil { + log.Error("Failed to encode OIDC JWKS response: %v", err) + } +} + +func oidcToken(resp http.ResponseWriter, req *http.Request) { + ctx := context.NewBaseContext(resp, req) + if !setting.OAuth2.Enabled { + ctx.HTTPError(http.StatusNotFound) + return + } + + task, err := getTaskFromOIDCTokenRequest(ctx) + if err != nil { + ctx.HTTPError(http.StatusUnauthorized, err.Error()) + return + } + if err := task.LoadAttributes(ctx); err != nil { + log.Error("Error runner api getting task attributes: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting task attributes") + return + } + + query := req.URL.Query() + if runID := query.Get("run_id"); runID != "" { + if runID != strconv.FormatInt(task.Job.RunID, 10) { + ctx.HTTPError(http.StatusUnauthorized, "OIDC run_id mismatch") + return + } + } + if jobID := query.Get("job_id"); jobID != "" { + if jobID != strconv.FormatInt(task.Job.ID, 10) { + ctx.HTTPError(http.StatusUnauthorized, "OIDC job_id mismatch") + return + } + } + + allowed, err := actions_service.TaskAllowsOIDCToken(ctx, task) + if err != nil { + log.Error("Error checking OIDC token permissions: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error checking OIDC permissions") + return + } + if !allowed { + ctx.HTTPError(http.StatusForbidden, "OIDC token permission not granted") + return + } + + audience := query.Get("audience") + issuedAt := time.Now().UTC() + expiresAt := issuedAt.Add(actions_service.OIDCTokenExpiry()) + token, err := actions_service.CreateOIDCToken(ctx, task, audience) + if err != nil { + log.Error("Error generating OIDC token: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error generating OIDC token") + return + } + + ctx.JSON(http.StatusOK, map[string]string{ + "value": token, + "issued_at": issuedAt.Format(time.RFC3339), + "expires_at": expiresAt.Format(time.RFC3339), + }) +} + +func getTaskFromOIDCTokenRequest(ctx *context.Base) (*actions_model.ActionTask, error) { + authHeader := ctx.Req.Header.Get("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + return nil, errors.New("bad authorization header") + } + + taskID, err := actions_service.ParseAuthorizationToken(ctx.Req) + if err != nil { + log.Error("Error parsing authorization token: %v", err) + return nil, errors.New("invalid authorization token") + } + if taskID == 0 { + return nil, errors.New("invalid authorization token") + } + + task, err := actions_model.GetTaskByID(ctx, taskID) + if err != nil { + log.Error("Error runner api getting task by ID: %v", err) + return nil, errors.New("error runner api getting task") + } + if task.Status != actions_model.StatusRunning { + return nil, errors.New("task is not running") + } + return task, nil +} diff --git a/services/actions/init.go b/services/actions/init.go index 7136da05ed8f3..34a1f6c1d2790 100644 --- a/services/actions/init.go +++ b/services/actions/init.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" notify_service "code.gitea.io/gitea/services/notify" + "code.gitea.io/gitea/services/oauth2_provider" ) func initGlobalRunnerToken(ctx context.Context) error { @@ -60,6 +61,15 @@ func Init(ctx context.Context) error { return nil } + if setting.OAuth2.Enabled { + if oauth2_provider.DefaultSigningKey == nil { + return errors.New("OIDC signing key is not initialized") + } + if oauth2_provider.DefaultSigningKey.IsSymmetric() { + return errors.New("OIDC signing key must be asymmetric") + } + } + jobEmitterQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "actions_ready_job", jobEmitterQueueHandler) if jobEmitterQueue == nil { return errors.New("unable to create actions_ready_job queue") diff --git a/services/actions/oidc.go b/services/actions/oidc.go new file mode 100644 index 0000000000000..d980eeb5a1897 --- /dev/null +++ b/services/actions/oidc.go @@ -0,0 +1,239 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + actions_module "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/oauth2_provider" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +const ( + actionsOIDCPath = "/api/actions/oidc" + actionsOIDCTokenPath = actionsOIDCPath + "/token" + actionsOIDCTokenExpiry = time.Hour +) + +type actionsOIDCClaims struct { + jwt.RegisteredClaims + Actor string `json:"actor"` + ActorID int64 `json:"actor_id"` + Repository string `json:"repository"` + RepositoryID int64 `json:"repository_id"` + RepositoryOwner string `json:"repository_owner"` + RepositoryOwnerID int64 `json:"repository_owner_id"` + RunID int64 `json:"run_id"` + RunNumber int64 `json:"run_number"` + RunAttempt int64 `json:"run_attempt"` + Workflow string `json:"workflow"` + WorkflowRef string `json:"workflow_ref,omitempty"` + WorkflowSHA string `json:"workflow_sha,omitempty"` + JobWorkflowRef string `json:"job_workflow_ref,omitempty"` + JobWorkflowSHA string `json:"job_workflow_sha,omitempty"` + RepositoryVisibility string `json:"repository_visibility,omitempty"` + EventName string `json:"event_name"` + Ref string `json:"ref"` + RefType string `json:"ref_type"` + Sha string `json:"sha"` + JobID string `json:"job_id"` + JobAttempt int64 `json:"job_attempt"` + BaseRef string `json:"base_ref,omitempty"` + HeadRef string `json:"head_ref,omitempty"` + RunnerEnvironment string `json:"runner_environment,omitempty"` + Environment string `json:"environment,omitempty"` +} + +// OIDCIssuer returns the issuer URL for Gitea Actions OIDC tokens. +func OIDCIssuer() string { + return strings.TrimSuffix(setting.AppURL, "/") + actionsOIDCPath +} + +// OIDCTokenRequestURL returns the URL for requesting an OIDC token. +func OIDCTokenRequestURL(task *actions_model.ActionTask) string { + base := strings.TrimSuffix(setting.AppURL, "/") + if task == nil || task.Job == nil { + return base + actionsOIDCTokenPath + } + return fmt.Sprintf("%s%s?job_id=%d&run_id=%d", base, actionsOIDCTokenPath, task.Job.ID, task.Job.RunID) +} + +// DefaultOIDCAudience returns the default audience used by OIDC tokens. +func DefaultOIDCAudience() string { + return strings.TrimSuffix(setting.AppURL, "/") +} + +// OIDCTokenExpiry returns the duration of issued OIDC tokens. +func OIDCTokenExpiry() time.Duration { + return actionsOIDCTokenExpiry +} + +// TaskAllowsOIDCToken reports whether a task is allowed to request an OIDC token. +func TaskAllowsOIDCToken(ctx context.Context, task *actions_model.ActionTask) (bool, error) { + if err := task.LoadJob(ctx); err != nil { + return false, err + } + if err := task.Job.LoadRepo(ctx); err != nil { + return false, err + } + if err := task.Job.Repo.LoadOwner(ctx); err != nil { + return false, err + } + + repoActionsCfg := task.Job.Repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() + ownerActionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, task.Job.Repo.OwnerID) + if err != nil { + return false, err + } + + var jobDeclaredPerms repo_model.ActionsTokenPermissions + if task.Job.TokenPermissions != nil { + jobDeclaredPerms = *task.Job.TokenPermissions + } else if repoActionsCfg.OverrideOwnerConfig { + jobDeclaredPerms = repoActionsCfg.GetDefaultTokenPermissions() + } else { + jobDeclaredPerms = ownerActionsCfg.GetDefaultTokenPermissions() + } + + if repoActionsCfg.OverrideOwnerConfig { + jobDeclaredPerms = repoActionsCfg.ClampPermissions(jobDeclaredPerms) + } else { + jobDeclaredPerms = ownerActionsCfg.ClampPermissions(jobDeclaredPerms) + } + + return jobDeclaredPerms.IDTokenAccessMode >= perm.AccessModeWrite, nil +} + +// CreateOIDCToken creates and signs an OIDC token for the actions task. +func CreateOIDCToken(ctx context.Context, task *actions_model.ActionTask, audience string) (string, error) { + if err := task.LoadJob(ctx); err != nil { + return "", err + } + if err := task.Job.LoadAttributes(ctx); err != nil { + return "", err + } + + signingKey := oauth2_provider.DefaultSigningKey + if signingKey == nil { + return "", errors.New("missing OIDC signing key") + } + + if audience == "" { + audience = DefaultOIDCAudience() + } + + if err := task.Job.Run.Repo.LoadOwner(ctx); err != nil { + return "", err + } + + ref, sha, refType, baseRef, headRef := resolveOIDCRefs(task.Job.Run) + // TODO: Not supported at the moment https://github.com/go-gitea/gitea/pull/35336 will implement it + environment := "prod" + subject := fmt.Sprintf("repo:%s:environment:%s", task.Job.Run.Repo.FullName(), environment) + now := time.Now() + runAttempt := task.Job.Attempt + if runAttempt == 0 { + runAttempt = task.Attempt + } + jobAttempt := task.Attempt + if jobAttempt == 0 { + jobAttempt = task.Job.Attempt + } + + claims := &actionsOIDCClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: OIDCIssuer(), + Subject: subject, + Audience: jwt.ClaimStrings{audience}, + ExpiresAt: jwt.NewNumericDate(now.Add(actionsOIDCTokenExpiry)), + NotBefore: jwt.NewNumericDate(now), + IssuedAt: jwt.NewNumericDate(now), + ID: uuid.NewString(), + }, + Actor: task.Job.Run.TriggerUser.Name, + ActorID: task.Job.Run.TriggerUser.ID, + Repository: task.Job.Run.Repo.FullName(), + RepositoryID: task.Job.Run.Repo.ID, + RepositoryOwner: task.Job.Run.Repo.OwnerName, + RepositoryOwnerID: task.Job.Run.Repo.OwnerID, + RunID: task.Job.Run.ID, + RunNumber: task.Job.Run.Index, + RunAttempt: runAttempt, + Workflow: task.Job.Run.WorkflowID, + WorkflowRef: buildWorkflowRef(task.Job.Run), + WorkflowSHA: task.Job.Run.CommitSHA, + JobWorkflowRef: buildWorkflowRef(task.Job.Run), + JobWorkflowSHA: task.Job.Run.CommitSHA, + RepositoryVisibility: repositoryVisibility(task.Job.Run.Repo), + EventName: task.Job.Run.TriggerEvent, + Ref: ref, + RefType: refType, + Sha: sha, + JobID: task.Job.JobID, + JobAttempt: jobAttempt, + BaseRef: baseRef, + HeadRef: headRef, + // TODO: Differentiate hosted vs self-hosted runners once runner metadata is available. + RunnerEnvironment: "self-hosted", + Environment: environment, + } + + token := jwt.NewWithClaims(signingKey.SigningMethod(), claims) + signingKey.PreProcessToken(token) + return token.SignedString(signingKey.SignKey()) +} + +func resolveOIDCRefs(run *actions_model.ActionRun) (ref, sha, refType, baseRef, headRef string) { + ref = run.Ref + sha = run.CommitSHA + if pullPayload, err := run.GetPullRequestEventPayload(); err == nil && pullPayload.PullRequest != nil && + pullPayload.PullRequest.Base != nil && pullPayload.PullRequest.Head != nil { + baseRef = pullPayload.PullRequest.Base.Ref + headRef = pullPayload.PullRequest.Head.Ref + if run.TriggerEvent == actions_module.GithubEventPullRequestTarget { + ref = git.BranchPrefix + pullPayload.PullRequest.Base.Name + sha = pullPayload.PullRequest.Base.Sha + } + } + refType = string(git.RefName(ref).RefType()) + return ref, sha, refType, baseRef, headRef +} + +func buildWorkflowRef(run *actions_model.ActionRun) string { + if run == nil || run.Repo == nil || run.WorkflowID == "" { + return "" + } + // TODO: When reusable workflows are supported, emit caller/callee refs separately. + return fmt.Sprintf("%s/%s@%s", run.Repo.FullName(), run.WorkflowID, run.Ref) +} + +func repositoryVisibility(repo *repo_model.Repository) string { + if repo.IsPrivate { + return "private" + } + switch repo.Owner.Visibility { + case structs.VisibleTypeLimited: + return "internal" + case structs.VisibleTypePrivate: + return "private" + case structs.VisibleTypePublic: + return "public" + default: + return "public" + } +} diff --git a/services/actions/oidc_test.go b/services/actions/oidc_test.go new file mode 100644 index 0000000000000..841ef01014116 --- /dev/null +++ b/services/actions/oidc_test.go @@ -0,0 +1,90 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/services/oauth2_provider" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type oidcTestClaims struct { + jwt.RegisteredClaims + Actor string `json:"actor"` + Repository string `json:"repository"` + RunID int64 `json:"run_id"` + JobID string `json:"job_id"` + Ref string `json:"ref"` + 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"` + RepositoryVisibility string `json:"repository_visibility"` +} + +func TestActionsOIDCTokenClaims(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + require.NoError(t, oauth2_provider.InitSigningKey()) + + task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 51}) + require.NoError(t, task.LoadJob(t.Context())) + + perms := repo_model.MakeActionsTokenPermissions(perm.AccessModeNone) + perms.IDTokenAccessMode = perm.AccessModeWrite + task.Job.TokenPermissions = &perms + _, err := db.GetEngine(t.Context()).ID(task.Job.ID).Cols("token_permissions").Update(task.Job) + require.NoError(t, err) + + allowed, err := TaskAllowsOIDCToken(t.Context(), task) + require.NoError(t, err) + assert.True(t, allowed) + + token, err := CreateOIDCToken(t.Context(), task, "test-audience") + require.NoError(t, err) + require.NotEmpty(t, token) + + var claims oidcTestClaims + signingKey := oauth2_provider.DefaultSigningKey + parsed, err := jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (any, error) { + if t.Method == nil || t.Method.Alg() != signingKey.SigningMethod().Alg() { + return nil, jwt.ErrSignatureInvalid + } + return signingKey.VerifyKey(), nil + }) + require.NoError(t, err) + require.True(t, parsed.Valid) + + assert.Equal(t, OIDCIssuer(), claims.Issuer) + assert.Contains(t, claims.Audience, "test-audience") + assert.Equal(t, task.Job.Run.Repo.FullName(), claims.Repository) + assert.Equal(t, task.Job.Run.TriggerUser.Name, claims.Actor) + assert.Equal(t, task.Job.Run.ID, claims.RunID) + assert.Equal(t, task.Job.JobID, claims.JobID) + assert.Equal(t, task.Job.Run.Ref, claims.Ref) + assert.Equal(t, "self-hosted", claims.RunnerEnvironment) + assert.Equal(t, buildWorkflowRef(task.Job.Run), claims.WorkflowRef) + assert.Equal(t, task.Job.Run.CommitSHA, claims.WorkflowSHA) + assert.Equal(t, buildWorkflowRef(task.Job.Run), claims.JobWorkflowRef) + assert.Equal(t, task.Job.Run.CommitSHA, claims.JobWorkflowSHA) + assert.Equal(t, repositoryVisibility(task.Job.Run.Repo), claims.RepositoryVisibility) + + perms.IDTokenAccessMode = perm.AccessModeNone + task.Job.TokenPermissions = &perms + _, err = db.GetEngine(t.Context()).ID(task.Job.ID).Cols("token_permissions").Update(task.Job) + require.NoError(t, err) + + allowed, err = TaskAllowsOIDCToken(t.Context(), task) + require.NoError(t, err) + assert.False(t, allowed) +} diff --git a/services/actions/permission_parser.go b/services/actions/permission_parser.go index 8c06e27d4b33c..ea2ed653a1685 100644 --- a/services/actions/permission_parser.go +++ b/services/actions/permission_parser.go @@ -63,7 +63,9 @@ 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)) + perms := repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite) + perms.IDTokenAccessMode = perm.AccessModeWrite + return &perms default: // Explicit but unrecognized scalar: return all-none permissions. return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone)) @@ -117,10 +119,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.IDTokenAccessMode = mode // 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) diff --git a/services/actions/permission_parser_test.go b/services/actions/permission_parser_test.go index 9986814b4850d..c03d8410e7ccc 100644 --- a/services/actions/permission_parser_test.go +++ b/services/actions/permission_parser_test.go @@ -31,6 +31,7 @@ func TestParseRawPermissions_ReadAll(t *testing.T) { assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeActions]) assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeWiki]) assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeProjects]) + assert.Equal(t, perm.AccessModeNone, result.IDTokenAccessMode) } // TestParseRawPermissions_GithubScopes verifies that all scopes that github supports are accounted for @@ -78,11 +79,13 @@ 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.Equal(t, perm.AccessModeWrite, result.IDTokenAccessMode) } func TestParseRawPermissions_IndividualScopes(t *testing.T) { yamlContent := ` contents: write +id-token: write issues: read pull-requests: none packages: write @@ -104,6 +107,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.Equal(t, perm.AccessModeWrite, result.IDTokenAccessMode) } func TestParseRawPermissions_Priority(t *testing.T) { diff --git a/services/actions/task.go b/services/actions/task.go index a21b600998727..d8fd3cd12518a 100644 --- a/services/actions/task.go +++ b/services/actions/task.go @@ -116,6 +116,10 @@ func generateTaskContext(t *actions_model.ActionTask) (*structpb.Struct, error) gitCtx := GenerateGiteaContext(t.Job.Run, t.Job) gitCtx["token"] = t.Token gitCtx["gitea_runtime_token"] = giteaRuntimeToken + if allowed, err := TaskAllowsOIDCToken(context.Background(), t); err == nil && allowed { + gitCtx["actions_id_token_request_url"] = OIDCTokenRequestURL(t) + gitCtx["actions_id_token_request_token"] = giteaRuntimeToken + } return structpb.NewStruct(gitCtx) } diff --git a/tests/integration/actions_oidc_test.go b/tests/integration/actions_oidc_test.go new file mode 100644 index 0000000000000..b60148d458dd8 --- /dev/null +++ b/tests/integration/actions_oidc_test.go @@ -0,0 +1,118 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/oauth2_provider" + + runnerv1 "code.gitea.io/actions-proto-go/runner/v1" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type oidcIntegrationClaims struct { + jwt.RegisteredClaims + Repository string `json:"repository"` + JobID string `json:"job_id"` + 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"` + RepositoryVisibility string `json:"repository_visibility"` + Environment string `json:"environment"` +} + +func TestActionsOIDCTokenIntegration(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, 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) + + workflowContent := `name: OIDC +on: + push: + paths: + - '.gitea/workflows/oidc.yml' +permissions: + id-token: write + +jobs: + oidc-job: + environment: production + runs-on: ubuntu-latest + steps: + - run: echo oidc +` + workflowPath := ".gitea/workflows/oidc.yml" + opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+workflowPath, workflowContent) + createWorkflowFile(t, token, user2.Name, repo.Name, workflowPath, opts) + + task := runner.fetchTask(t) + contextMap := task.Context.AsMap() + requestURL, ok := contextMap["actions_id_token_request_url"].(string) + require.True(t, ok) + requestToken, ok := contextMap["actions_id_token_request_token"].(string) + require.True(t, ok) + + parsedURL, err := url.Parse(requestURL) + require.NoError(t, err) + query := parsedURL.Query() + query.Set("audience", "integration-test") + parsedURL.RawQuery = query.Encode() + + req := NewRequest(t, http.MethodGet, parsedURL.RequestURI()) + req.Header.Set("Authorization", "Bearer "+requestToken) + resp := MakeRequest(t, req, http.StatusOK) + var tokenResp struct { + Value string `json:"value"` + } + DecodeJSON(t, resp, &tokenResp) + require.NotEmpty(t, tokenResp.Value) + + var claims oidcIntegrationClaims + signingKey := oauth2_provider.DefaultSigningKey + parsed, err := jwt.ParseWithClaims(tokenResp.Value, &claims, func(t *jwt.Token) (any, error) { + if t.Method == nil || t.Method.Alg() != signingKey.SigningMethod().Alg() { + return nil, jwt.ErrSignatureInvalid + } + return signingKey.VerifyKey(), nil + }) + require.NoError(t, err) + require.True(t, parsed.Valid) + + assert.Equal(t, actions_service.OIDCIssuer(), claims.Issuer) + assert.Contains(t, claims.Audience, "integration-test") + assert.Equal(t, repo.FullName, claims.Repository) + assert.Equal(t, "oidc-job", claims.JobID) + assert.Equal(t, "self-hosted", claims.RunnerEnvironment) + assert.Equal(t, "public", claims.RepositoryVisibility) + assert.Equal(t, "production", claims.Environment) + + refValue, ok := contextMap["ref"].(string) + require.True(t, ok) + shaValue, ok := contextMap["sha"].(string) + require.True(t, ok) + workflowRef := repo.FullName + "/" + workflowPath + "@" + refValue + assert.Equal(t, workflowRef, claims.WorkflowRef) + assert.Equal(t, shaValue, claims.WorkflowSHA) + assert.Equal(t, workflowRef, claims.JobWorkflowRef) + assert.Equal(t, shaValue, claims.JobWorkflowSHA) + + runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) + }) +}