allow the actions user to login via the jwt token (#32527)

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.

(cherry picked from commit 407b6e6dfc7ee9ebb8a16c7f1a786e4c24d0516e)

Conflicts:
	services/auth/oauth2.go
  trivial context conflicts because OAuth2 scopes are in Forgejo and
  not yet in Gitea
This commit is contained in:
Rowan Bohde 2024-11-20 09:24:09 -06:00 committed by Earl Warren
parent 1c04f8f10a
commit 48872d11ca
No known key found for this signature in database
GPG key ID: 0579CB2928A78A00
4 changed files with 105 additions and 4 deletions

View 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 id: 47
job_id: 192 job_id: 192

View file

@ -83,7 +83,12 @@ func ParseAuthorizationToken(req *http.Request) (int64, error) {
return 0, fmt.Errorf("split token failed") 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 { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) 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 return 0, err
} }
c, ok := token.Claims.(*actionsClaims) c, ok := parsedToken.Claims.(*actionsClaims)
if !token.Valid || !ok { if !parsedToken.Valid || !ok {
return 0, fmt.Errorf("invalid token claim") return 0, fmt.Errorf("invalid token claim")
} }

View file

@ -18,6 +18,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/actions"
"code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/auth/source/oauth2"
) )
@ -94,6 +95,18 @@ func CheckOAuthAccessToken(ctx context.Context, accessToken string) (int64, stri
return grant.UserID, grantScopes return grant.UserID, grantScopes
} }
// 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 // OAuth2 implements the Auth interface and authenticates requests
// (API requests only) by looking for an OAuth token in query parameters or the // (API requests only) by looking for an OAuth token in query parameters or the
// "Authorization" header. // "Authorization" header.
@ -137,8 +150,17 @@ func parseToken(req *http.Request) (string, bool) {
func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 { func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 {
// Let's see if token is valid. // Let's see if token is valid.
if strings.Contains(tokenSHA, ".") { if strings.Contains(tokenSHA, ".") {
uid, grantScopes := CheckOAuthAccessToken(ctx, 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, grantScopes := CheckOAuthAccessToken(ctx, tokenSHA)
if uid != 0 { if uid != 0 {
store.GetData()["IsApiToken"] = true store.GetData()["IsApiToken"] = true
if grantScopes != "" { if grantScopes != "" {

View file

@ -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"
"github.com/stretchr/testify/require"
)
func TestUserIDFromToken(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
t.Run("Actions JWT", func(t *testing.T) {
const RunningTaskID = 47
token, err := actions.CreateAuthorizationToken(RunningTaskID, 1, 2)
require.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, true, ds["IsActionsToken"])
assert.Equal(t, ds["ActionsTaskID"], int64(RunningTaskID))
})
}
func TestCheckTaskIsRunning(t *testing.T) {
require.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)
})
}
}