]> source.dussan.org Git - gitea.git/commitdiff
allow the actions user to login via the jwt token (#32527)
authorRowan Bohde <rowan.bohde@gmail.com>
Wed, 20 Nov 2024 15:24:09 +0000 (09:24 -0600)
committerGitHub <noreply@github.com>
Wed, 20 Nov 2024 15:24:09 +0000 (15:24 +0000)
We have some actions that leverage the Gitea API that began receiving
401 errors, with a message that the user was not found. These actions
use the `ACTIONS_RUNTIME_TOKEN` env var in the actions job to
authenticate with the Gitea API. The format of this env var in actions
jobs changed with go-gitea/gitea/pull/28885 to be a JWT (with a
corresponding update to `act_runner`) 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.

Make ACTIONS_RUNTIME_TOKEN in action runners could be used,
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.

models/fixtures/action_task.yml
services/actions/auth.go
services/auth/oauth2.go
services/auth/oauth2_test.go [new file with mode: 0644]

index 443effe08c92a1cbbab5a36948d3cf7de3531ed6..d88a8ed8a918977dd27738469b7647b90fe708a5 100644 (file)
@@ -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
index 8e934d89a84c868b2d3c84d032f3e6708d742092..1ef21f6e0eb09bf2b36f0348d797298cb784e8e0 100644 (file)
@@ -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")
        }
 
index d0aec085b107d8a8ee9c8cd388c01b38ab5649d9..251ae5a244b5c240df4c6fb4b42055b58c78b03d 100644 (file)
@@ -17,6 +17,7 @@ import (
        "code.gitea.io/gitea/modules/setting"
        "code.gitea.io/gitea/modules/timeutil"
        "code.gitea.io/gitea/modules/web/middleware"
+       "code.gitea.io/gitea/services/actions"
        "code.gitea.io/gitea/services/oauth2_provider"
 )
 
@@ -54,6 +55,18 @@ func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 {
        return grant.UserID
 }
 
+// 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 {
+               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.
@@ -97,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 CheckTaskIsRunning(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
diff --git a/services/auth/oauth2_test.go b/services/auth/oauth2_test.go
new file mode 100644 (file)
index 0000000..75c231f
--- /dev/null
@@ -0,0 +1,55 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+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 TestCheckTaskIsRunning(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 := CheckTaskIsRunning(context.Background(), c.TaskID)
+                       assert.Equal(t, c.Expected, actual)
+               })
+       }
+}