enhance(actions): add OIDC workload identity support (#39052)

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>
This commit is contained in:
Max P. authored and GitHub committed 2026-10-18 07:08:55 +00:00
1 parent 31626de2a6
commit a4908d4af4
28 files changed
+717 -87

No files matched your search

+1 -1
View File
@@ -64,7 +64,7 @@ func (cfg *OwnerActionsConfig) GetMaxTokenPermissions() repo_model.ActionsTokenP
return *cfg.MaxTokenPermissions
}
// Default max is write for everything
return repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite)
return repo_model.MakeWriteAllActionsTokenPermissions()
}
// ClampPermissions ensures that the given permissions don't exceed the maximum
+2
View File
@@ -110,6 +110,8 @@ type ActionRunJob struct {
// ReusableWorkflowContent is the content of the reusable workflow specified by "uses:".
// Only set when IsReusableCaller is true.
ReusableWorkflowContent []byte `xorm:"LONGBLOB"`
// ReusableWorkflowRef is the fully qualified ref, or the commit, that "uses:" resolved to when the content was loaded.
ReusableWorkflowRef string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"`
// CallSecrets encodes the reusable workflow caller's "secrets:" section:
// - "" : no "secrets:" section (children only see auto-generated tokens).
// - "inherit" : the caller wrote "secrets: inherit".
+9 -1
View File
@@ -29,6 +29,7 @@ func (ActionsTokenPermissionMode) EnumValues() []ActionsTokenPermissionMode {
// ActionsTokenPermissions defines the permissions for different repository units
type ActionsTokenPermissions struct {
UnitAccessModes map[unit.Type]perm.AccessMode `json:"unit_access_modes,omitempty"`
IDToken bool `json:"id_token,omitempty"` // default modes never grant it, like on GitHub
}
var ActionsTokenUnitTypes = []unit.Type{
@@ -50,12 +51,19 @@ func MakeActionsTokenPermissions(unitAccessMode perm.AccessMode) (ret ActionsTok
return ret
}
func MakeWriteAllActionsTokenPermissions() ActionsTokenPermissions {
ret := MakeActionsTokenPermissions(perm.AccessModeWrite)
ret.IDToken = true
return ret
}
// ClampActionsTokenPermissions ensures that the given permissions don't exceed the maximum
func ClampActionsTokenPermissions(p1, p2 ActionsTokenPermissions) (ret ActionsTokenPermissions) {
ret.UnitAccessModes = make(map[unit.Type]perm.AccessMode)
for _, ut := range ActionsTokenUnitTypes {
ret.UnitAccessModes[ut] = min(p1.UnitAccessModes[ut], p2.UnitAccessModes[ut])
}
ret.IDToken = p1.IDToken && p2.IDToken
return ret
}
@@ -156,7 +164,7 @@ func (cfg *ActionsConfig) GetMaxTokenPermissions() ActionsTokenPermissions {
return *cfg.MaxTokenPermissions
}
// Default max is write for everything
return MakeActionsTokenPermissions(perm.AccessModeWrite)
return MakeWriteAllActionsTokenPermissions()
}
// ClampPermissions ensures that the given permissions don't exceed the maximum
+4
View File
@@ -57,6 +57,8 @@ func TestActionsConfigTokenPermissions(t *testing.T) {
assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeCode])
assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeIssues])
assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypePackages])
assert.False(t, perms.IDToken)
assert.True(t, cfg.GetMaxTokenPermissions().IDToken)
})
t.Run("Effective Permissions - Restricted Mode", func(t *testing.T) {
@@ -92,6 +94,7 @@ func TestActionsConfigTokenPermissions(t *testing.T) {
unit.TypeActions: perm.AccessModeRead, // Should be clamped to None
unit.TypeWiki: perm.AccessModeRead, // Should stay Read
},
IDToken: true,
}
clamped := cfg.ClampPermissions(input)
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeCode])
@@ -100,5 +103,6 @@ func TestActionsConfigTokenPermissions(t *testing.T) {
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypePackages])
assert.Equal(t, perm.AccessModeNone, clamped.UnitAccessModes[unit.TypeActions])
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeWiki])
assert.False(t, clamped.IDToken)
})
}