Skip to content

OIDC provider #25664

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: Add OIDC provider for actions
  • Loading branch information
sorenisanerd committed Sep 16, 2023
commit 724e138bd865b8fef1117a830ec05fb3ac0fc22f
4 changes: 4 additions & 0 deletions models/actions/run_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ func (job *ActionRunJob) LoadAttributes(ctx context.Context) error {
return job.Run.LoadAttributes(ctx)
}

func (job *ActionRunJob) MayCreateIDToken() bool {
return job.Permissions.IDToken == PermissionWrite
}

func GetRunJobByID(ctx context.Context, id int64) (*ActionRunJob, error) {
var job ActionRunJob
has, err := db.GetEngine(ctx).Where("id=?", id).Get(&job)
Expand Down
15 changes: 13 additions & 2 deletions routers/api/actions/runner/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct {
ref, sha, baseRef, headRef := t.Job.Run.RefShaBaseRefAndHeadRef()
refName := git.RefName(ref)

taskContext, err := structpb.NewStruct(map[string]any{
contextMap := map[string]any{
// standard contexts, see https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
"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.
"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.
Expand Down Expand Up @@ -160,7 +160,18 @@ func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct {

// additional contexts
"gitea_default_actions_url": setting.Actions.DefaultActionsURL.URL(),
})
}

if t.Job.MayCreateIDToken() {
// The "a=1" is a dummy variable. If an audience is passed to
// github/core.js's getIdToken(), it appends it to the URL as "&audience=".
// If the URL doesn't at least have a '?', the "&audience=" part will be
// interpreted as part of the path.
contextMap["actions_id_token_request_url"] = fmt.Sprintf("%sapi/v1/actions/id-token/request?a=1", setting.AppURL)
contextMap["actions_id_token_request_token"] = t.Token
}

taskContext, err := structpb.NewStruct(contextMap)
if err != nil {
log.Error("structpb.NewStruct failed: %v", err)
}
Expand Down
2 changes: 2 additions & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,8 @@ func Routes() *web.Route {
}, reqToken())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken())

m.Get("/actions/id-token/request", generateOIDCToken)

// Repositories (requires repo scope, org scope)
m.Post("/org/{org}/repos",
tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository),
Expand Down
154 changes: 154 additions & 0 deletions routers/api/v1/oidc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright 2016 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

// OIDC provider for Gitea Actions
package v1

import (
"fmt"
"net/http"

actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
auth_service "code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/auth/source/oauth2"

"github.com/golang-jwt/jwt/v5"
)

type IDTokenResponse struct {
Value string `json:"value"`
Count int `json:"count"`
}

type IDTokenErrorResponse struct {
ErrorDescription string `json:"error_description"`
}

type IDToken struct {
jwt.RegisteredClaims

Ref string `json:"ref,omitempty"`
SHA string `json:"sha,omitempty"`
Repository string `json:"repository,omitempty"`
RepositoryOwner string `json:"repository_owner,omitempty"`
RepositoryOwnerID int `json:"repository_owner_id,omitempty"`
RunID int `json:"run_id,omitempty"`
RunNumber int `json:"run_number,omitempty"`
RunAttempt int `json:"run_attempt,omitempty"`
RepositoryVisibility string `json:"repository_visibility,omitempty"`
RepositoryID int `json:"repository_id,omitempty"`
ActorID int `json:"actor_id,omitempty"`
Actor string `json:"actor,omitempty"`
Workflow string `json:"workflow,omitempty"`
EventName string `json:"event_name,omitempty"`
RefType string `json:"ref_type,omitempty"`
HeadRef string `json:"head_ref,omitempty"`
BaseRef string `json:"base_ref,omitempty"`

// Github's OIDC tokens have all of these, but I wasn't sure how
// to populate them. Leaving them here to make future work easier.

/*
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"`
RunnerEnvironment string `json:"runner_environment,omitempty"`
*/
}

func generateOIDCToken(ctx *context.APIContext) {
if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() || ctx.Data["IsActionsToken"] != true {
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
return
}

task := ctx.Data["ActionsTask"].(*actions_model.ActionTask)
if err := task.LoadJob(ctx); err != nil {
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
return
}

if mayCreateToken := task.Job.MayCreateIDToken(); !mayCreateToken {
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
return
}

if err := task.Job.LoadAttributes(ctx); err != nil {
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
return
}

if err := task.Job.Run.LoadAttributes(ctx); err != nil {
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
return
}

if err := task.Job.Run.Repo.LoadAttributes(ctx); err != nil {
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
return
}

eventName := task.Job.Run.EventName()
ref, sha, baseRef, headRef := task.Job.Run.RefShaBaseRefAndHeadRef()

jwtAudience := jwt.ClaimStrings{task.Job.Run.Repo.Owner.HTMLURL()}
requestedAudience := ctx.Req.URL.Query().Get("audience")
if requestedAudience != "" {
jwtAudience = append(jwtAudience, requestedAudience)
}

// generate OIDC token
issueTime := timeutil.TimeStampNow()
expirationTime := timeutil.TimeStampNow().Add(15 * 60)
notBeforeTime := timeutil.TimeStampNow().Add(-15 * 60)
idToken := &IDToken{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: setting.AppURL,
Audience: jwtAudience,
ExpiresAt: jwt.NewNumericDate(expirationTime.AsTime()),
NotBefore: jwt.NewNumericDate(notBeforeTime.AsTime()),
IssuedAt: jwt.NewNumericDate(issueTime.AsTime()),
Subject: fmt.Sprintf("repo:%s:ref:%s", task.Job.Run.Repo.FullName(), ref),
},
Ref: ref,
SHA: sha,
Repository: task.Job.Run.Repo.FullName(),
RepositoryOwner: task.Job.Run.Repo.OwnerName,
RepositoryOwnerID: int(task.Job.Run.Repo.OwnerID),
RunID: int(task.Job.RunID),
RunNumber: int(task.Job.Run.Index),
RunAttempt: int(task.Job.Attempt),
RepositoryID: int(task.Job.Run.RepoID),
ActorID: int(task.Job.Run.TriggerUserID),
Actor: task.Job.Run.TriggerUser.Name,
Workflow: task.Job.Run.WorkflowID,
EventName: eventName,
RefType: git.RefName(task.Job.Run.Ref).RefType(),
BaseRef: baseRef,
HeadRef: headRef,
}

if task.Job.Run.Repo.IsPrivate {
idToken.RepositoryVisibility = "private"
} else {
idToken.RepositoryVisibility = "public"
}

signedIDToken, err := oauth2.SignToken(idToken, oauth2.DefaultSigningKey)
if err != nil {
ctx.JSON(http.StatusInternalServerError, &IDTokenErrorResponse{
ErrorDescription: "unable to sign token",
})
return
}

ctx.JSON(http.StatusOK, IDTokenResponse{
Value: signedIDToken,
Count: len(signedIDToken),
})
}
1 change: 1 addition & 0 deletions services/auth/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store Dat

store.GetData()["IsActionsToken"] = true
store.GetData()["ActionsTaskID"] = task.ID
store.GetData()["ActionsTask"] = task
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can load the task from database when we need it but not store it in context?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I honestly don't remember why I did it this way. It's been a while. Let me take another look.


return user_model.ActionsUserID
}
Expand Down