Files
zitadel/internal/api/oidc/token_exchange_test.go
T
8e442ed4bc fix(oidc): allow OrgRoleIDScope downscoping in token exchange (#12563)
# Which Problems Are Solved

- Token exchange rejects `urn:zitadel:iam:org:roles🆔{orgID}` when
that scope is not already present on the subject or actor token
(`invalid_scope`).
- A documented least-privilege pattern — exchanging a broad access token
for one whose roles claims are limited to a single organization — has
been broken since the v4.15.3 token-exchange hardening and was not
restored by #12312.
- A naive allow-any-`roles:id` exception would let a previously narrowed
token widen again (switch org filter, or drop the filter and get all
role orgs), including via an actor token's filter on actor-path
exchanges.
- A client-requested bare `urn:zitadel:iam:org:roles🆔` (empty org id)
is not a meaningful filter and should be rejected on exchange.

# How the Problems Are Solved

- Treats `OrgRoleIDScope` as a restriction filter with downscope-only
semantics via `applyOrgRoleIDScopeDownscoping(requested,
ceilingScopes)`:
- union / standard exchange passes the **subject** token scopes as the
ceiling (actor filters ignored)
- impersonation (scopeless `user_id` / `id_token` subject) passes the
**actor** token scopes as the ceiling
- ceiling unfiltered → any requested `roles:id` is allowed (broad →
narrow)
  - ceiling filtered → requested filters must be ⊆ that set
- ceiling filtered but request omits them → inherit the ceiling (omit
must not re-widen)
- Rejects a **client-requested** bare `urn:zitadel:iam:org:roles🆔` in
`validateTokenExchangeScopes` after normalize (before empty-request
fallback). Input tokens may still carry the bare prefix as a degenerate
ceiling; empty-request / omit inherit does not re-trigger the rejection.
- Keeps non-restriction scopes on the existing subject ∪ actor presence
checks.
- Adds unit coverage for downscope, further downscope, widen rejection,
omit-inherit, mixed subject/actor filters, empty-scope access/JWT
subjects, and bare-prefix request/ceiling cases.
- Adds an integration test for broad → `roles:id` filtered roles, plus a
second exchange that rejects widening to another org.
- Documents downscope-only and omit-inherit behavior in the reserved
scopes table and the token exchange guide.

# Additional Changes

- Updates the token-exchange guide Scope section to reflect that
authorization-related reserved scopes must already be present on input
tokens (aligned with the v4.15.3 hardening), rather than suggesting
scope can be freely extended.

# Additional Context

- Closes #12413
- Follow-up for PR #12312
- Related discussion in #11869 (recommended `roles:id` workaround; this
restores that path for token exchange without allowing filter widening)
- Behavior note for release notes: during token exchange, an existing
`urn:zitadel:iam:org:roles🆔*` filter on the subject is inherited when
omitted from the request, so role claims cannot re-widen.

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Livio Spring <[email protected]>
2026-08-28 16:27:56 +02:00

301 lines
11 KiB
Go

package oidc
import (
"slices"
"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("exchange: OrgRoleIDScope downscoping allowed", func(t *testing.T) {
subjectScopes := []string{oidc.ScopeOpenID, oidc.ScopeProfile}
orgRoleScope := domain.OrgRoleIDScope + "388047065096336384"
got, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID, orgRoleScope},
subjectScopes, subjectScopes, false)
require.NoError(t, err)
assert.Equal(t, []string{oidc.ScopeOpenID, orgRoleScope}, got)
})
t.Run("exchange: OrgRoleIDScope further downscoping allowed", func(t *testing.T) {
orgA := domain.OrgRoleIDScope + "orgA"
orgB := domain.OrgRoleIDScope + "orgB"
subjectScopes := []string{oidc.ScopeOpenID, orgA, orgB}
got, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID, orgA},
subjectScopes, subjectScopes, false)
require.NoError(t, err)
assert.Equal(t, []string{oidc.ScopeOpenID, orgA}, got)
})
t.Run("exchange: OrgRoleIDScope widening rejected", func(t *testing.T) {
orgA := domain.OrgRoleIDScope + "orgA"
orgB := domain.OrgRoleIDScope + "orgB"
subjectScopes := []string{oidc.ScopeOpenID, orgA}
_, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID, orgB},
subjectScopes, subjectScopes, false)
require.Error(t, err)
})
t.Run("exchange: omitting OrgRoleIDScope inherits subject filter", func(t *testing.T) {
orgA := domain.OrgRoleIDScope + "orgA"
subjectScopes := []string{oidc.ScopeOpenID, oidc.ScopeProfile, orgA}
got, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID},
subjectScopes, subjectScopes, false)
require.NoError(t, err)
assert.Equal(t, []string{oidc.ScopeOpenID, orgA}, got)
})
t.Run("actor path: subject filter is ceiling, actor filter ignored", func(t *testing.T) {
orgA := domain.OrgRoleIDScope + "orgA"
orgB := domain.OrgRoleIDScope + "orgB"
subjectScopes := []string{oidc.ScopeOpenID, oidc.ScopeProfile, orgA}
actorWithOtherFilter := append(slices.Clone(actorScopes), orgB)
_, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID, orgB},
subjectScopes, actorWithOtherFilter, false)
require.Error(t, err)
got, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID, orgA},
subjectScopes, actorWithOtherFilter, false)
require.NoError(t, err)
assert.Equal(t, []string{oidc.ScopeOpenID, orgA}, got)
})
t.Run("actor path: unfiltered subject ignores actor OrgRoleIDScope ceiling", func(t *testing.T) {
// Subject-primary: only the subject's filter is a ceiling. An unfiltered
// subject may still request any roles:id even if the actor is filtered.
orgA := domain.OrgRoleIDScope + "orgA"
orgB := domain.OrgRoleIDScope + "orgB"
subjectScopes := []string{oidc.ScopeOpenID, oidc.ScopeProfile}
actorWithFilter := append(slices.Clone(actorScopes), orgA)
got, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID, orgB},
subjectScopes, actorWithFilter, false)
require.NoError(t, err)
assert.Equal(t, []string{oidc.ScopeOpenID, orgB}, got)
})
t.Run("union path: empty-scope subject ignores actor OrgRoleIDScope ceiling", func(t *testing.T) {
// Access/JWT subjects with an empty scope claim still use the union path.
// An empty subject is unfiltered, so actor roles:id must not become a ceiling.
orgA := domain.OrgRoleIDScope + "orgA"
orgB := domain.OrgRoleIDScope + "orgB"
actorWithFilter := append(slices.Clone(actorScopes), orgB)
got, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID, orgA},
nil, actorWithFilter, false)
require.NoError(t, err)
assert.Equal(t, []string{oidc.ScopeOpenID, orgA}, got)
})
t.Run("union path: empty-scope subject omit does not inherit actor filter", func(t *testing.T) {
orgB := domain.OrgRoleIDScope + "orgB"
actorWithFilter := append(slices.Clone(actorScopes), orgB)
got, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID},
nil, actorWithFilter, false)
require.NoError(t, err)
assert.Equal(t, []string{oidc.ScopeOpenID}, got)
assert.NotContains(t, got, orgB)
})
t.Run("exchange: bare OrgRoleIDScope request rejected", func(t *testing.T) {
subjectScopes := []string{oidc.ScopeOpenID, oidc.ScopeProfile}
_, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID, domain.OrgRoleIDScope},
subjectScopes, subjectScopes, false)
require.Error(t, err)
})
t.Run("impersonation: bare OrgRoleIDScope request rejected", func(t *testing.T) {
_, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID, domain.OrgRoleIDScope},
nil, actorScopes, true)
require.Error(t, err)
})
t.Run("exchange: bare OrgRoleIDScope on subject remains a ceiling", func(t *testing.T) {
orgA := domain.OrgRoleIDScope + "orgA"
subjectScopes := []string{oidc.ScopeOpenID, domain.OrgRoleIDScope}
_, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID, orgA},
subjectScopes, subjectScopes, false)
require.Error(t, err)
})
t.Run("exchange: omit inherits bare OrgRoleIDScope ceiling", func(t *testing.T) {
subjectScopes := []string{oidc.ScopeOpenID, oidc.ScopeProfile, domain.OrgRoleIDScope}
got, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID},
subjectScopes, subjectScopes, false)
require.NoError(t, err)
assert.Equal(t, []string{oidc.ScopeOpenID, domain.OrgRoleIDScope}, got)
})
t.Run("exchange: empty request inherits bare OrgRoleIDScope from subject", func(t *testing.T) {
subjectScopes := []string{oidc.ScopeOpenID, domain.OrgRoleIDScope}
got, err := validateTokenExchangeScopes(client, nil, subjectScopes, subjectScopes, false)
require.NoError(t, err)
assert.Equal(t, subjectScopes, got)
})
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: OrgRoleIDScope not on actor allowed", func(t *testing.T) {
orgRoleScope := domain.OrgRoleIDScope + "388047065096336384"
got, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID, orgRoleScope},
nil, actorScopes, true)
require.NoError(t, err)
assert.Contains(t, got, orgRoleScope)
})
t.Run("impersonation: OrgRoleIDScope widening beyond actor rejected", func(t *testing.T) {
orgA := domain.OrgRoleIDScope + "orgA"
orgB := domain.OrgRoleIDScope + "orgB"
actorWithFilter := append(slices.Clone(actorScopes), orgA)
_, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID, orgB},
nil, actorWithFilter, true)
require.Error(t, err)
})
t.Run("impersonation: omitting OrgRoleIDScope inherits actor filter", func(t *testing.T) {
orgA := domain.OrgRoleIDScope + "orgA"
actorWithFilter := append(slices.Clone(actorScopes), orgA)
got, err := validateTokenExchangeScopes(client,
[]string{oidc.ScopeOpenID},
nil, actorWithFilter, true)
require.NoError(t, err)
assert.Equal(t, []string{oidc.ScopeOpenID, orgA}, got)
})
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},
{domain.OrgRoleIDScope + "388047065096336384", false},
{ScopeUserMetaData, false},
}
for _, tt := range tests {
t.Run(tt.scope, func(t *testing.T) {
assert.Equal(t, tt.want, isTokenExchangeAuthorizationScope(tt.scope))
})
}
}
func Test_isTokenExchangeRestrictionScope(t *testing.T) {
tests := []struct {
scope string
want bool
}{
{domain.OrgRoleIDScope + "388047065096336384", true},
{domain.OrgRoleIDScope, true},
{oidc.ScopeOpenID, false},
{oidc.ScopeOfflineAccess, false},
{ScopeProjectsRoles, false},
{domain.OrgIDScope + "388047065096336384", false},
{ScopeProjectRolePrefix + "admin", false},
}
for _, tt := range tests {
t.Run(tt.scope, func(t *testing.T) {
assert.Equal(t, tt.want, isTokenExchangeRestrictionScope(tt.scope))
})
}
}