Skip to content

Commit 724e138

Browse files
committed
feat: Add OIDC provider for actions
1 parent 8271be5 commit 724e138

File tree

5 files changed

+174
-2
lines changed

5 files changed

+174
-2
lines changed

models/actions/run_job.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ func (job *ActionRunJob) LoadAttributes(ctx context.Context) error {
7272
return job.Run.LoadAttributes(ctx)
7373
}
7474

75+
func (job *ActionRunJob) MayCreateIDToken() bool {
76+
return job.Permissions.IDToken == PermissionWrite
77+
}
78+
7579
func GetRunJobByID(ctx context.Context, id int64) (*ActionRunJob, error) {
7680
var job ActionRunJob
7781
has, err := db.GetEngine(ctx).Where("id=?", id).Get(&job)

routers/api/actions/runner/utils.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct {
121121
ref, sha, baseRef, headRef := t.Job.Run.RefShaBaseRefAndHeadRef()
122122
refName := git.RefName(ref)
123123

124-
taskContext, err := structpb.NewStruct(map[string]any{
124+
contextMap := map[string]any{
125125
// standard contexts, see https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
126126
"action": "", // string, The name of the action currently running, or the id of a step. GitHub removes special characters, and uses the name __run when the current step runs a script without an id. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name __run, and the second script will be named __run_2. Similarly, the second invocation of actions/checkout will be actionscheckout2.
127127
"action_path": "", // string, The path where an action is located. This property is only supported in composite actions. You can use this path to access files located in the same repository as the action.
@@ -160,7 +160,18 @@ func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct {
160160

161161
// additional contexts
162162
"gitea_default_actions_url": setting.Actions.DefaultActionsURL.URL(),
163-
})
163+
}
164+
165+
if t.Job.MayCreateIDToken() {
166+
// The "a=1" is a dummy variable. If an audience is passed to
167+
// github/core.js's getIdToken(), it appends it to the URL as "&audience=".
168+
// If the URL doesn't at least have a '?', the "&audience=" part will be
169+
// interpreted as part of the path.
170+
contextMap["actions_id_token_request_url"] = fmt.Sprintf("%sapi/v1/actions/id-token/request?a=1", setting.AppURL)
171+
contextMap["actions_id_token_request_token"] = t.Token
172+
}
173+
174+
taskContext, err := structpb.NewStruct(contextMap)
164175
if err != nil {
165176
log.Error("structpb.NewStruct failed: %v", err)
166177
}

routers/api/v1/api.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,8 @@ func Routes() *web.Route {
10201020
}, reqToken())
10211021
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken())
10221022

1023+
m.Get("/actions/id-token/request", generateOIDCToken)
1024+
10231025
// Repositories (requires repo scope, org scope)
10241026
m.Post("/org/{org}/repos",
10251027
tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository),

routers/api/v1/oidc.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright 2016 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
// OIDC provider for Gitea Actions
5+
package v1
6+
7+
import (
8+
"fmt"
9+
"net/http"
10+
11+
actions_model "code.gitea.io/gitea/models/actions"
12+
"code.gitea.io/gitea/modules/context"
13+
"code.gitea.io/gitea/modules/git"
14+
"code.gitea.io/gitea/modules/setting"
15+
"code.gitea.io/gitea/modules/timeutil"
16+
auth_service "code.gitea.io/gitea/services/auth"
17+
"code.gitea.io/gitea/services/auth/source/oauth2"
18+
19+
"github.com/golang-jwt/jwt/v5"
20+
)
21+
22+
type IDTokenResponse struct {
23+
Value string `json:"value"`
24+
Count int `json:"count"`
25+
}
26+
27+
type IDTokenErrorResponse struct {
28+
ErrorDescription string `json:"error_description"`
29+
}
30+
31+
type IDToken struct {
32+
jwt.RegisteredClaims
33+
34+
Ref string `json:"ref,omitempty"`
35+
SHA string `json:"sha,omitempty"`
36+
Repository string `json:"repository,omitempty"`
37+
RepositoryOwner string `json:"repository_owner,omitempty"`
38+
RepositoryOwnerID int `json:"repository_owner_id,omitempty"`
39+
RunID int `json:"run_id,omitempty"`
40+
RunNumber int `json:"run_number,omitempty"`
41+
RunAttempt int `json:"run_attempt,omitempty"`
42+
RepositoryVisibility string `json:"repository_visibility,omitempty"`
43+
RepositoryID int `json:"repository_id,omitempty"`
44+
ActorID int `json:"actor_id,omitempty"`
45+
Actor string `json:"actor,omitempty"`
46+
Workflow string `json:"workflow,omitempty"`
47+
EventName string `json:"event_name,omitempty"`
48+
RefType string `json:"ref_type,omitempty"`
49+
HeadRef string `json:"head_ref,omitempty"`
50+
BaseRef string `json:"base_ref,omitempty"`
51+
52+
// Github's OIDC tokens have all of these, but I wasn't sure how
53+
// to populate them. Leaving them here to make future work easier.
54+
55+
/*
56+
WorkflowRef string `json:"workflow_ref,omitempty"`
57+
WorkflowSHA string `json:"workflow_sha,omitempty"`
58+
JobWorkflowRef string `json:"job_workflow_ref,omitempty"`
59+
JobWorkflowSHA string `json:"job_workflow_sha,omitempty"`
60+
RunnerEnvironment string `json:"runner_environment,omitempty"`
61+
*/
62+
}
63+
64+
func generateOIDCToken(ctx *context.APIContext) {
65+
if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() || ctx.Data["IsActionsToken"] != true {
66+
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
67+
return
68+
}
69+
70+
task := ctx.Data["ActionsTask"].(*actions_model.ActionTask)
71+
if err := task.LoadJob(ctx); err != nil {
72+
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
73+
return
74+
}
75+
76+
if mayCreateToken := task.Job.MayCreateIDToken(); !mayCreateToken {
77+
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
78+
return
79+
}
80+
81+
if err := task.Job.LoadAttributes(ctx); err != nil {
82+
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
83+
return
84+
}
85+
86+
if err := task.Job.Run.LoadAttributes(ctx); err != nil {
87+
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
88+
return
89+
}
90+
91+
if err := task.Job.Run.Repo.LoadAttributes(ctx); err != nil {
92+
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
93+
return
94+
}
95+
96+
eventName := task.Job.Run.EventName()
97+
ref, sha, baseRef, headRef := task.Job.Run.RefShaBaseRefAndHeadRef()
98+
99+
jwtAudience := jwt.ClaimStrings{task.Job.Run.Repo.Owner.HTMLURL()}
100+
requestedAudience := ctx.Req.URL.Query().Get("audience")
101+
if requestedAudience != "" {
102+
jwtAudience = append(jwtAudience, requestedAudience)
103+
}
104+
105+
// generate OIDC token
106+
issueTime := timeutil.TimeStampNow()
107+
expirationTime := timeutil.TimeStampNow().Add(15 * 60)
108+
notBeforeTime := timeutil.TimeStampNow().Add(-15 * 60)
109+
idToken := &IDToken{
110+
RegisteredClaims: jwt.RegisteredClaims{
111+
Issuer: setting.AppURL,
112+
Audience: jwtAudience,
113+
ExpiresAt: jwt.NewNumericDate(expirationTime.AsTime()),
114+
NotBefore: jwt.NewNumericDate(notBeforeTime.AsTime()),
115+
IssuedAt: jwt.NewNumericDate(issueTime.AsTime()),
116+
Subject: fmt.Sprintf("repo:%s:ref:%s", task.Job.Run.Repo.FullName(), ref),
117+
},
118+
Ref: ref,
119+
SHA: sha,
120+
Repository: task.Job.Run.Repo.FullName(),
121+
RepositoryOwner: task.Job.Run.Repo.OwnerName,
122+
RepositoryOwnerID: int(task.Job.Run.Repo.OwnerID),
123+
RunID: int(task.Job.RunID),
124+
RunNumber: int(task.Job.Run.Index),
125+
RunAttempt: int(task.Job.Attempt),
126+
RepositoryID: int(task.Job.Run.RepoID),
127+
ActorID: int(task.Job.Run.TriggerUserID),
128+
Actor: task.Job.Run.TriggerUser.Name,
129+
Workflow: task.Job.Run.WorkflowID,
130+
EventName: eventName,
131+
RefType: git.RefName(task.Job.Run.Ref).RefType(),
132+
BaseRef: baseRef,
133+
HeadRef: headRef,
134+
}
135+
136+
if task.Job.Run.Repo.IsPrivate {
137+
idToken.RepositoryVisibility = "private"
138+
} else {
139+
idToken.RepositoryVisibility = "public"
140+
}
141+
142+
signedIDToken, err := oauth2.SignToken(idToken, oauth2.DefaultSigningKey)
143+
if err != nil {
144+
ctx.JSON(http.StatusInternalServerError, &IDTokenErrorResponse{
145+
ErrorDescription: "unable to sign token",
146+
})
147+
return
148+
}
149+
150+
ctx.JSON(http.StatusOK, IDTokenResponse{
151+
Value: signedIDToken,
152+
Count: len(signedIDToken),
153+
})
154+
}

services/auth/oauth2.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store Dat
103103

104104
store.GetData()["IsActionsToken"] = true
105105
store.GetData()["ActionsTaskID"] = task.ID
106+
store.GetData()["ActionsTask"] = task
106107

107108
return user_model.ActionsUserID
108109
}

0 commit comments

Comments
 (0)