mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-20 08:20:04 +00:00
Adds OpenID Connect workload identity to Gitea Actions: jobs declaring `id-token: write` can request short-lived tokens to authenticate to cloud providers and other services without stored secrets. Tokens follow GitHub's format, so clients like `@actions/core` and existing trust policies carry over. - Issuer at `/api/actions/oidc` with discovery and JWKS, signed with a dedicated RS256 key - GitHub's claims, including `workflow_ref`, `job_workflow_ref` and the immutable `repo:owner@id/repo@id:ref:...` subject - `id-token: write` or `write-all` grant it, default permissions and fork pull requests never get it, owner and repository maximum permissions can disable it - Reusable workflows record the exact ref they were loaded from, and dispatch rejects refs that only resolve through a same-named tag - Generated signing keys are written atomically, and generated ES384 and ES512 OAuth2 keys use their own curves instead of P-256 - `github.ref_type` is `branch` for pull request runs, like on GitHub Fixes https://github.com/go-gitea/gitea/issues/26383 Fixes https://github.com/go-gitea/gitea/issues/33681 Docs: https://gitea.com/gitea/docs/pulls/526 Assisted-by: ChatGPT:gpt-5.6-sol, Claude Code:claude-opus-5-5 Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: silverwind <me@silverwind.io>
113 lines
2.7 KiB
Go
113 lines
2.7 KiB
Go
// Copyright 2024 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package actions
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dev/modules/json"
|
|
"gitea.dev/modules/log"
|
|
"gitea.dev/modules/setting"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
const idTokenRequestScope = "Actions.IDTokenRequest" // separate from the runtime token, which runners also send to cache servers
|
|
|
|
type actionsClaims struct {
|
|
jwt.RegisteredClaims
|
|
Scp string `json:"scp"`
|
|
TaskID int64
|
|
RunID int64
|
|
JobID int64
|
|
Ac string `json:"ac"`
|
|
}
|
|
|
|
type actionsCacheScope struct {
|
|
Scope string
|
|
Permission actionsCachePermission
|
|
}
|
|
|
|
type actionsCachePermission int
|
|
|
|
const (
|
|
actionsCachePermissionRead = 1 << iota
|
|
actionsCachePermissionWrite
|
|
)
|
|
|
|
func CreateAuthorizationToken(taskID, runID, jobID int64) (string, error) {
|
|
ac, err := json.Marshal(&[]actionsCacheScope{
|
|
{
|
|
Scope: "",
|
|
Permission: actionsCachePermissionWrite,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return signActionsClaims(actionsClaims{
|
|
Scp: fmt.Sprintf("Actions.Results:%d:%d", runID, jobID),
|
|
Ac: string(ac),
|
|
TaskID: taskID,
|
|
RunID: runID,
|
|
JobID: jobID,
|
|
})
|
|
}
|
|
|
|
func signActionsClaims(claims actionsClaims) (string, error) {
|
|
now := time.Now()
|
|
claims.RegisteredClaims = jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(1*time.Hour + setting.Actions.EndlessTaskTimeout)),
|
|
NotBefore: jwt.NewNumericDate(now),
|
|
}
|
|
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(setting.GetGeneralTokenSigningSecret())
|
|
}
|
|
|
|
func ParseAuthorizationToken(req *http.Request) (int64, error) {
|
|
h := req.Header.Get("Authorization")
|
|
if h == "" {
|
|
return 0, nil
|
|
}
|
|
|
|
parts := strings.SplitN(h, " ", 2)
|
|
if len(parts) != 2 {
|
|
log.Error("split token failed: %s", h)
|
|
return 0, errors.New("split token failed")
|
|
}
|
|
|
|
return TokenToTaskID(parts[1])
|
|
}
|
|
|
|
// TokenToTaskID returns the TaskID associated with the provided JWT token
|
|
func TokenToTaskID(token string) (int64, error) {
|
|
return taskIDFromToken(token, false)
|
|
}
|
|
|
|
func taskIDFromToken(token string, idTokenRequest bool) (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"])
|
|
}
|
|
return setting.GetGeneralTokenSigningSecret(), nil
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
claims, ok := parsedToken.Claims.(*actionsClaims)
|
|
if !parsedToken.Valid || !ok {
|
|
return 0, errors.New("invalid token claim")
|
|
}
|
|
if (claims.Scp == idTokenRequestScope) != idTokenRequest {
|
|
return 0, errors.New("unexpected token scope")
|
|
}
|
|
|
|
return claims.TaskID, nil
|
|
}
|