mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-20 14:34:25 +00:00
## Which Problems Are Solved The v4.15.3 token exchange hardening (`#12319`, `#12322`) introduced two regressions: 1. **Logic bug**: scope validation used intersection (`!inSubject || !inActor`) instead of union, so any explicit `scope` on a `user_id` subject always failed — even `openid` when present on the actor token. 2. **Design gap**: even with union logic, scope-less subjects (`user_id`, `id_token`) cannot satisfy a rule that requires all requested scopes to exist on input tokens. This breaks documented impersonation flows (e.g. service account actor + `user_id` subject requesting `email`). ## How the Problems Are Solved - **Standard exchange** (no actor, or subject carries scopes): keep union validation — requested scopes must be ⊆ subject ∪ actor. For plain exchange this is effectively a subset of the subject token. - **Scope-less subject impersonation** (`user_id`, `id_token` on actor path): split validation: - **Subject-data scopes** (`openid`, `profile`, `email`, …): client allowlist only - **Authorization scopes** (`offline_access`, `:aud`, `projects:roles`, `role:*`): still ⊆ subject ∪ actor - Restore subject → actor fallback when `scope` is omitted. GHSA protections remain: cross-client token binding and authorization-scope escalation are still rejected. Closes #12319 Closes #12322 ## Additional Changes - Unit tests for scope validators (`token_exchange_test.go`) - Integration tests covering `user_id`/`id_token` paths, union-path rejection, and client-credentials actor → `user_id` exchange ## Additional Context - Relates to https://github.com/zitadel/zitadel/releases/tag/v4.15.3 - Security fix: GHSA-vrh8-c9cm-wh8v --------- Co-authored-by: Cursor <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]>
113 lines
3.5 KiB
Go
113 lines
3.5 KiB
Go
package oidc
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/zitadel/oidc/v3/pkg/oidc"
|
|
|
|
"github.com/zitadel/zitadel/internal/domain"
|
|
)
|
|
|
|
func testTokenExchangeClient() *Client {
|
|
return &Client{}
|
|
}
|
|
|
|
func Test_validateTokenExchangeScopes(t *testing.T) {
|
|
client := testTokenExchangeClient()
|
|
actorScopes := []string{
|
|
oidc.ScopeOpenID,
|
|
oidc.ScopeProfile,
|
|
ScopeProjectsRoles,
|
|
ScopeProjectRolePrefix + "TOKEN_EXCHANGE_IMPERSONATOR",
|
|
domain.ProjectIDScope + "288694447250664408" + domain.AudSuffix,
|
|
}
|
|
|
|
t.Run("exchange: subset of subject", func(t *testing.T) {
|
|
subjectScopes := []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeOfflineAccess}
|
|
got, err := validateTokenExchangeScopes(client,
|
|
[]string{oidc.ScopeOpenID, oidc.ScopeProfile},
|
|
subjectScopes, subjectScopes, false)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{oidc.ScopeOpenID, oidc.ScopeProfile}, got)
|
|
})
|
|
|
|
t.Run("exchange: escalation rejected", func(t *testing.T) {
|
|
subjectScopes := []string{oidc.ScopeOpenID}
|
|
_, err := validateTokenExchangeScopes(client,
|
|
[]string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess},
|
|
subjectScopes, subjectScopes, false)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("impersonation: subject-data scope not on actor allowed", func(t *testing.T) {
|
|
got, err := validateTokenExchangeScopes(client,
|
|
[]string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail},
|
|
nil, actorScopes, true)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, got, oidc.ScopeEmail)
|
|
})
|
|
|
|
t.Run("impersonation: authorization scope not on actor rejected", func(t *testing.T) {
|
|
_, err := validateTokenExchangeScopes(client,
|
|
[]string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess},
|
|
nil, actorScopes, true)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("impersonation: authorization scope on actor allowed", func(t *testing.T) {
|
|
got, err := validateTokenExchangeScopes(client,
|
|
[]string{oidc.ScopeOpenID, ScopeProjectsRoles, domain.ProjectIDScope + "288694447250664408" + domain.AudSuffix},
|
|
nil, actorScopes, true)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, got, ScopeProjectsRoles)
|
|
})
|
|
|
|
t.Run("impersonation: omitted scope inherits actor", func(t *testing.T) {
|
|
got, err := validateTokenExchangeScopes(client, nil, nil, actorScopes, true)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{
|
|
oidc.ScopeOpenID,
|
|
oidc.ScopeProfile,
|
|
ScopeProjectsRoles,
|
|
domain.ProjectIDScope + "288694447250664408" + domain.AudSuffix,
|
|
}, got)
|
|
})
|
|
|
|
t.Run("actor path with subject scopes uses exchange validator", func(t *testing.T) {
|
|
subjectScopes := []string{oidc.ScopeOpenID, oidc.ScopeProfile}
|
|
_, err := validateTokenExchangeScopes(client,
|
|
[]string{oidc.ScopeEmail},
|
|
subjectScopes, actorScopes, false)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("actor path with JWT subject and empty scopes uses exchange validator", func(t *testing.T) {
|
|
_, err := validateTokenExchangeScopes(client,
|
|
[]string{oidc.ScopeEmail},
|
|
nil, actorScopes, false)
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func Test_isTokenExchangeAuthorizationScope(t *testing.T) {
|
|
tests := []struct {
|
|
scope string
|
|
want bool
|
|
}{
|
|
{oidc.ScopeOpenID, false},
|
|
{oidc.ScopeEmail, false},
|
|
{oidc.ScopeOfflineAccess, true},
|
|
{ScopeProjectsRoles, true},
|
|
{ScopeProjectRolePrefix + "admin", true},
|
|
{domain.ProjectIDScope + "proj" + domain.AudSuffix, true},
|
|
{ScopeUserMetaData, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.scope, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, isTokenExchangeAuthorizationScope(tt.scope))
|
|
})
|
|
}
|
|
}
|