From 91b01eaa690d72eff94f4433f7851de7608bd6b9 Mon Sep 17 00:00:00 2001 From: Rowan Bohde Date: Fri, 15 Nov 2024 13:08:07 -0600 Subject: [PATCH 1/5] allow the actions user to login via the jwt token The format of the env var `ACTIONS_RUNTIME_TOKEN` in actions jobs changed with go-gitea/gitea/pull/28885 to be a JWT. Since it was a JWT, the OAuth parsing logic attempted to parse it as an OAuth token, and would return user not found, instead of falling back to look up the running task and assigning it to the actions user. This restores that functionality by parsing Actions JWTs first, and then attempting to parse OAUTH JWTs. The code to parse potential old `ACTION_RUNTIME_TOKEN` was kept in case someone is running an older version of act_runner that doesn't support the Actions JWT. --- services/auth/oauth2.go | 48 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index d0aec085b107d..6d11a1703c83d 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -6,6 +6,7 @@ package auth import ( "context" + "errors" "net/http" "strings" "time" @@ -16,7 +17,9 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/services/oauth2_provider" ) @@ -131,6 +134,40 @@ func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store Dat return t.UID } +// parseActionJWT identifies actions runner JWTs that look like an +// OAuth token, but needs to be parsed by its code +func parseActionsJWT(req *http.Request, store DataStore) (*user_model.User, error) { + taskID, err := actions.ParseAuthorizationToken(req) + if err != nil || taskID == 0 { + return nil, nil + } + + // Verify the task exists + task, err := actions_model.GetTaskByID(req.Context(), taskID) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + return nil, nil + } + + return nil, err + } + + // Verify that it's running + if task.Status != actions_model.StatusRunning { + return nil, nil + } + + store.GetData()["IsActionsToken"] = true + store.GetData()["ActionsTaskID"] = taskID + + user, err := user_model.GetPossibleUserByID(req.Context(), user_model.ActionsUserID) + if err != nil { + return nil, err + } + + return user, nil +} + // Verify extracts the user ID from the OAuth token in the query parameters // or the "Authorization" header and returns the corresponding user object for that ID. // If verification is successful returns an existing user object. @@ -142,6 +179,15 @@ func (o *OAuth2) Verify(req *http.Request, w http.ResponseWriter, store DataStor return nil, nil } + user, err := parseActionsJWT(req, store) + if err != nil { + return nil, err + } + + if user != nil { + return user, nil + } + token, ok := parseToken(req) if !ok { return nil, nil @@ -154,7 +200,7 @@ func (o *OAuth2) Verify(req *http.Request, w http.ResponseWriter, store DataStor } log.Trace("OAuth2 Authorization: Found token for user[%d]", id) - user, err := user_model.GetPossibleUserByID(req.Context(), id) + user, err = user_model.GetPossibleUserByID(req.Context(), id) if err != nil { if !user_model.IsErrUserNotExist(err) { log.Error("GetUserByName: %v", err) From 6e876cc9c630219f892edae5bf7a6a364392b863 Mon Sep 17 00:00:00 2001 From: Rowan Bohde Date: Tue, 19 Nov 2024 09:28:05 -0600 Subject: [PATCH 2/5] incorporate logic into userIDFromToken instead of a standalone function --- services/actions/auth.go | 11 +++++-- services/auth/oauth2.go | 69 ++++++++++++++-------------------------- 2 files changed, 31 insertions(+), 49 deletions(-) diff --git a/services/actions/auth.go b/services/actions/auth.go index 8e934d89a84c8..1ef21f6e0eb09 100644 --- a/services/actions/auth.go +++ b/services/actions/auth.go @@ -83,7 +83,12 @@ func ParseAuthorizationToken(req *http.Request) (int64, error) { return 0, fmt.Errorf("split token failed") } - token, err := jwt.ParseWithClaims(parts[1], &actionsClaims{}, func(t *jwt.Token) (any, error) { + return TokenToTaskID(parts[1]) +} + +// TokenToTaskID returns the TaskID associated with the provided JWT token +func TokenToTaskID(token string) (int64, error) { + parsedToken, err := jwt.ParseWithClaims(token, &actionsClaims{}, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } @@ -93,8 +98,8 @@ func ParseAuthorizationToken(req *http.Request) (int64, error) { return 0, err } - c, ok := token.Claims.(*actionsClaims) - if !token.Valid || !ok { + c, ok := parsedToken.Claims.(*actionsClaims) + if !parsedToken.Valid || !ok { return 0, fmt.Errorf("invalid token claim") } diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index 6d11a1703c83d..c3d37e2345775 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -6,7 +6,6 @@ package auth import ( "context" - "errors" "net/http" "strings" "time" @@ -17,7 +16,6 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/services/oauth2_provider" @@ -57,6 +55,18 @@ func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 { return grant.UserID } +// CheckTaskID verifies that the TaskID corresponds to a running task +func CheckTaskID(ctx context.Context, taskID int64) bool { + // Verify the task exists + task, err := actions_model.GetTaskByID(ctx, taskID) + if err != nil { + return false + } + + // Verify that it's running + return task.Status == actions_model.StatusRunning +} + // OAuth2 implements the Auth interface and authenticates requests // (API requests only) by looking for an OAuth token in query parameters or the // "Authorization" header. @@ -100,6 +110,16 @@ func parseToken(req *http.Request) (string, bool) { func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 { // Let's see if token is valid. if strings.Contains(tokenSHA, ".") { + // First attempt to decode an actions JWT, returning the actions user + if taskID, err := actions.TokenToTaskID(tokenSHA); err == nil { + if CheckTaskID(ctx, taskID) { + store.GetData()["IsActionsToken"] = true + store.GetData()["ActionsTaskID"] = taskID + return user_model.ActionsUserID + } + } + + // Otherwise, check if this is an OAuth access token uid := CheckOAuthAccessToken(ctx, tokenSHA) if uid != 0 { store.GetData()["IsApiToken"] = true @@ -134,40 +154,6 @@ func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store Dat return t.UID } -// parseActionJWT identifies actions runner JWTs that look like an -// OAuth token, but needs to be parsed by its code -func parseActionsJWT(req *http.Request, store DataStore) (*user_model.User, error) { - taskID, err := actions.ParseAuthorizationToken(req) - if err != nil || taskID == 0 { - return nil, nil - } - - // Verify the task exists - task, err := actions_model.GetTaskByID(req.Context(), taskID) - if err != nil { - if errors.Is(err, util.ErrNotExist) { - return nil, nil - } - - return nil, err - } - - // Verify that it's running - if task.Status != actions_model.StatusRunning { - return nil, nil - } - - store.GetData()["IsActionsToken"] = true - store.GetData()["ActionsTaskID"] = taskID - - user, err := user_model.GetPossibleUserByID(req.Context(), user_model.ActionsUserID) - if err != nil { - return nil, err - } - - return user, nil -} - // Verify extracts the user ID from the OAuth token in the query parameters // or the "Authorization" header and returns the corresponding user object for that ID. // If verification is successful returns an existing user object. @@ -179,15 +165,6 @@ func (o *OAuth2) Verify(req *http.Request, w http.ResponseWriter, store DataStor return nil, nil } - user, err := parseActionsJWT(req, store) - if err != nil { - return nil, err - } - - if user != nil { - return user, nil - } - token, ok := parseToken(req) if !ok { return nil, nil @@ -200,7 +177,7 @@ func (o *OAuth2) Verify(req *http.Request, w http.ResponseWriter, store DataStor } log.Trace("OAuth2 Authorization: Found token for user[%d]", id) - user, err = user_model.GetPossibleUserByID(req.Context(), id) + user, err := user_model.GetPossibleUserByID(req.Context(), id) if err != nil { if !user_model.IsErrUserNotExist(err) { log.Error("GetUserByName: %v", err) From f5c0b53d55b9c14b12bd0ba7228f656654e22ac9 Mon Sep 17 00:00:00 2001 From: Rowan Bohde Date: Tue, 19 Nov 2024 13:03:57 -0600 Subject: [PATCH 3/5] add tests for new functionality in auth/oauth.go --- models/fixtures/action_task.yml | 19 ++++++++++++ services/auth/oauth2_test.go | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 services/auth/oauth2_test.go diff --git a/models/fixtures/action_task.yml b/models/fixtures/action_task.yml index 443effe08c92a..d88a8ed8a9189 100644 --- a/models/fixtures/action_task.yml +++ b/models/fixtures/action_task.yml @@ -1,3 +1,22 @@ +- + id: 46 + attempt: 3 + runner_id: 1 + status: 3 # 3 is the status code for "cancelled" + started: 1683636528 + stopped: 1683636626 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: 0 + token_hash: 6d8ef48297195edcc8e22c70b3020eaa06c52976db67d39b4260c64a69a2cc1508825121b7b8394e48e00b1bf8718b2aaaaa + token_salt: eeeeeeee + token_last_eight: eeeeeeee + log_filename: artifact-test2/2f/47.log + log_in_storage: 1 + log_length: 707 + log_size: 90179 + log_expired: 0 - id: 47 job_id: 192 diff --git a/services/auth/oauth2_test.go b/services/auth/oauth2_test.go new file mode 100644 index 0000000000000..a926b60b66b37 --- /dev/null +++ b/services/auth/oauth2_test.go @@ -0,0 +1,52 @@ +package auth + +import ( + "context" + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/actions" + + "github.com/stretchr/testify/assert" +) + +func TestUserIDFromToken(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + t.Run("Actions JWT", func(t *testing.T) { + const RunningTaskID = 47 + token, err := actions.CreateAuthorizationToken(RunningTaskID, 1, 2) + assert.NoError(t, err) + + ds := make(middleware.ContextData) + + o := OAuth2{} + uid := o.userIDFromToken(context.Background(), token, ds) + assert.Equal(t, int64(user_model.ActionsUserID), uid) + assert.Equal(t, ds["IsActionsToken"], true) + assert.Equal(t, ds["ActionsTaskID"], int64(RunningTaskID)) + }) +} + +func TestCheckTaskID(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + cases := map[string]struct { + TaskID int64 + Expected bool + }{ + "Running": {TaskID: 47, Expected: true}, + "Missing": {TaskID: 1, Expected: false}, + "Cancelled": {TaskID: 46, Expected: false}, + } + + for name := range cases { + c := cases[name] + t.Run(name, func(t *testing.T) { + actual := CheckTaskID(context.Background(), c.TaskID) + assert.Equal(t, c.Expected, actual) + }) + } +} From 77c705680b1e20eb59770c3eefefe61f34dcf55f Mon Sep 17 00:00:00 2001 From: Rowan Bohde Date: Tue, 19 Nov 2024 13:24:14 -0600 Subject: [PATCH 4/5] lint --- services/auth/oauth2_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/auth/oauth2_test.go b/services/auth/oauth2_test.go index a926b60b66b37..b934ba51dee17 100644 --- a/services/auth/oauth2_test.go +++ b/services/auth/oauth2_test.go @@ -1,3 +1,6 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + package auth import ( From 6aafb51f289de2660484165cdfa100528d5d2852 Mon Sep 17 00:00:00 2001 From: Rowan Bohde Date: Wed, 20 Nov 2024 08:47:39 -0600 Subject: [PATCH 5/5] rename CheckTaskID to CheckTaskIsRunning --- services/auth/oauth2.go | 6 +++--- services/auth/oauth2_test.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index c3d37e2345775..251ae5a244b5c 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -55,8 +55,8 @@ func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 { return grant.UserID } -// CheckTaskID verifies that the TaskID corresponds to a running task -func CheckTaskID(ctx context.Context, taskID int64) bool { +// CheckTaskIsRunning verifies that the TaskID corresponds to a running task +func CheckTaskIsRunning(ctx context.Context, taskID int64) bool { // Verify the task exists task, err := actions_model.GetTaskByID(ctx, taskID) if err != nil { @@ -112,7 +112,7 @@ func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store Dat if strings.Contains(tokenSHA, ".") { // First attempt to decode an actions JWT, returning the actions user if taskID, err := actions.TokenToTaskID(tokenSHA); err == nil { - if CheckTaskID(ctx, taskID) { + if CheckTaskIsRunning(ctx, taskID) { store.GetData()["IsActionsToken"] = true store.GetData()["ActionsTaskID"] = taskID return user_model.ActionsUserID diff --git a/services/auth/oauth2_test.go b/services/auth/oauth2_test.go index b934ba51dee17..75c231ff7a4ae 100644 --- a/services/auth/oauth2_test.go +++ b/services/auth/oauth2_test.go @@ -33,7 +33,7 @@ func TestUserIDFromToken(t *testing.T) { }) } -func TestCheckTaskID(t *testing.T) { +func TestCheckTaskIsRunning(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) cases := map[string]struct { @@ -48,7 +48,7 @@ func TestCheckTaskID(t *testing.T) { for name := range cases { c := cases[name] t.Run(name, func(t *testing.T) { - actual := CheckTaskID(context.Background(), c.TaskID) + actual := CheckTaskIsRunning(context.Background(), c.TaskID) assert.Equal(t, c.Expected, actual) }) }