Files
zitadel/internal/api/oidc/token_exchange_test.go
T
39ad66bded fix: correct scope validation in token exchange (#12312)
## 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]>
2026-07-09 12:33:11 +00:00

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))
})
}
}