mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
# 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]>
301 lines
11 KiB
Go
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))
|
|
})
|
|
}
|
|
}
|