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]>
This commit is contained in:
Livio Spring
2026-08-28 16:27:56 +02:00
committed by GitHub
co-authored by Cursor Agent Livio Spring
parent 94f53145da
commit 8e442ed4bc
5 changed files with 371 additions and 12 deletions
@@ -27,7 +27,7 @@ In addition to the standard compliant scopes, we use the following scopes.
| `urn:zitadel:iam:org:projects:roles` | `urn:zitadel:iam:org:projects:roles` | By using this scope a client can request the claim `urn:zitadel:iam:org:project:{projectid}:roles` to be asserted for each requested project. All projects of the token audience, requested by the `urn:zitadel:iam:org:project:id:{projectid}:aud` scopes will be used. |
| `urn:zitadel:iam:org:id:{id}` | `urn:zitadel:iam:org:id:178204173316174381` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization. If the organization does not exist a failure is displayed. It will assert the `urn:zitadel:iam:user:resourceowner` claims. |
| `urn:zitadel:iam:org:domain:primary:{domainname}` | `urn:zitadel:iam:org:domain:primary:acme.ch` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization and the username is suffixed by the provided domain. If the organization does not exist a failure is displayed |
| `urn:zitadel:iam:org:roles:id:{orgID}` | `urn:zitadel:iam:org:roles:id:178204173316174381` | This scope can be used one or more times to limit the granted organization IDs in the returned roles. Unknown organization IDs are ignored. When this scope is not used, all granted organizations are returned inside the roles. |
| `urn:zitadel:iam:org:roles:id:{orgID}` | `urn:zitadel:iam:org:roles:id:178204173316174381` | This scope can be used one or more times to limit the granted organization IDs in the returned roles. Unknown organization IDs are ignored. When this scope is not used, all granted organizations are returned inside the roles. In [token exchange](/guides/integrate/token-exchange), the filter is **downscope-only**: you may narrow further, but not switch to an organization that was not already on the subject token. If the subject token already carries this filter and the exchange request omits it, the filter is inherited so role claims cannot re-widen. |
| `urn:zitadel:iam:org:project:id:{projectid}:aud` | `urn:zitadel:iam:org:project:id:69234237810729019:aud` | By adding this scope, the requested project id will be added to the audience of the access token |
| `urn:zitadel:iam:org:project:id:zitadel:aud` | `urn:zitadel:iam:org:project:id:zitadel:aud` | By adding this scope, the ZITADEL project id will be added to the audience of the access token |
| `urn:zitadel:iam:user:metadata` | `urn:zitadel:iam:user:metadata` | By adding this scope, the metadata of the user will be included in the token. The values are base64 encoded. |
@@ -113,10 +113,12 @@ Currently ZITADEL supports requesting of:
#### Scope
[Scope](/apis/openidoauth/scopes) is an optional parameter that allows changing the scope of the supplied token, for the requested token. Scope can be entirely different from any of the supplied tokens. It can be used to extend or decrease the scope of the new token.
[Scope](/apis/openidoauth/scopes) is an optional parameter that allows changing the scope of the supplied token, for the requested token. Scope can be used to decrease privileges carried by the input tokens. Authorization-related reserved scopes (for example project audience or `offline_access`) must already be present on the `subject_token` and/or `actor_token`.
When scope is omitted in the request, it is taken from the `subject_token`. If the `subject_token` doesn't carry any scope (some types can't), it is taken from the `actor_token`. All allowed token types for the `actor_token` typically have a scope.
The reserved scope `urn:zitadel:iam:org:roles:id:{orgID}` is special: it only filters which organization IDs appear in role claims and never grants additional roles. During token exchange it is **downscope-only**. You may add it to narrow a broad token, or further reduce an existing filter, but you cannot switch to an organization that was not already allowed by the subject token. If the subject token already includes that filter and the exchange request omits it, ZITADEL inherits the filter so omitting scope cannot re-widen role claims.
#### Audience
Audience is an optional parameter that allows to decrease the audience of the requested token. When supplied it may never contain an audience which was not already present in either the `subject_token` or `actor_token` combined.
@@ -24,6 +24,7 @@ import (
"github.com/zitadel/zitadel/pkg/grpc/admin"
"github.com/zitadel/zitadel/pkg/grpc/app"
"github.com/zitadel/zitadel/pkg/grpc/management"
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
)
func setImpersonationPolicy(t *testing.T, instance *integration.Instance, value bool) {
@@ -740,3 +741,95 @@ func TestImpersonation_API_Call(t *testing.T) {
assert.Equal(t, codes.PermissionDenied, status.Code())
assert.Equal(t, "Errors.TokenExchange.Token.NotForAPI (APP-Shi0J)", status.Message())
}
// TestServer_TokenExchange_OrgRoleIDScopeDownscope covers #12413: a broad access
// token can be exchanged with urn:zitadel:iam:org:roles:id:{orgID} even when that
// scope was not present on the subject token, and roles claims are filtered.
func TestServer_TokenExchange_OrgRoleIDScopeDownscope(t *testing.T) {
const (
roleFoo = "foo"
roleBar = "bar"
)
project := Instance.CreateProject(CTX, t, "", integration.ProjectName(), false, false)
client, keyData, err := Instance.CreateOIDCWebClientJWT(CTX, redirectURI, logoutRedirectURI, project.GetId(),
app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE,
app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE,
app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN,
)
require.NoError(t, err)
_, err = Instance.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{
Id: project.GetId(),
Name: integration.ProjectName(),
ProjectRoleAssertion: true,
})
require.NoError(t, err)
addProjectRolesGrants(t, User.GetUserId(), project.GetId(), roleFoo, roleBar)
grantedOrgID := addProjectOrgGrant(t, User.GetUserId(), project.GetId(), roleFoo, roleBar)
authRequestID := createAuthRequest(t, Instance, client.GetClientId(), redirectURI, oidc.ScopeOpenID, oidc.ScopeProfile)
sessionID, sessionToken, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
AuthRequestId: authRequestID,
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
Session: &oidc_pb.Session{
SessionId: sessionID,
SessionToken: sessionToken,
},
},
})
require.NoError(t, err)
provider, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), client.GetClientId(), "", redirectURI, []string{oidc.ScopeOpenID},
rp.WithJWTProfile(rp.SignerFromKeyFile(keyData)))
require.NoError(t, err)
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
broadTokens, err := rp.CodeExchange[*oidc.IDTokenClaims](context.Background(), code, provider, codeExchangeOptions(t, provider)...)
require.NoError(t, err)
broadUserinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, broadTokens.AccessToken, broadTokens.TokenType, User.GetUserId(), provider)
require.NoError(t, err)
assertProjectRoleClaims(t, project.GetId(), broadUserinfo.Claims, true, []string{roleFoo, roleBar}, []string{Instance.DefaultOrg.Id, grantedOrgID})
signer, err := rp.SignerFromKeyFile(keyData)()
require.NoError(t, err)
exchanger, err := tokenexchange.NewTokenExchangerJWTProfile(CTX, Instance.OIDCIssuer(), client.GetClientId(), signer)
require.NoError(t, err)
orgRoleScope := domain.OrgRoleIDScope + grantedOrgID
exchanged, err := tokenexchange.ExchangeToken(
CTX,
exchanger,
broadTokens.AccessToken,
oidc.AccessTokenType,
"",
"",
nil,
nil,
[]string{oidc.ScopeOpenID, orgRoleScope},
oidc.AccessTokenType,
)
require.NoError(t, err)
assert.Contains(t, []string(exchanged.Scopes), orgRoleScope)
narrowUserinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, exchanged.AccessToken, exchanged.TokenType, User.GetUserId(), provider)
require.NoError(t, err)
assertProjectRoleClaims(t, project.GetId(), narrowUserinfo.Claims, true, []string{roleFoo, roleBar}, []string{grantedOrgID})
// Widening to another org after downscoping must fail.
_, err = tokenexchange.ExchangeToken(
CTX,
exchanger,
exchanged.AccessToken,
oidc.AccessTokenType,
"",
"",
nil,
nil,
[]string{oidc.ScopeOpenID, domain.OrgRoleIDScope + Instance.DefaultOrg.Id},
oidc.AccessTokenType,
)
require.Error(t, err)
}
+86 -10
View File
@@ -192,13 +192,23 @@ func (s *Server) jwtProfileUserCheck(ctx context.Context, resourceOwner *string,
// validateTokenExchangeScopes picks the scope rule set for this exchange.
// scopelessActorPath selects impersonation rules (user_id/id_token subjects
// on the actor path); otherwise every requested scope must ⊆ subject ∪ actor.
// on the actor path); otherwise every requested scope must ⊆ subject ∪ actor,
// with OrgRoleIDScope handled as a downscoping filter (see
// applyOrgRoleIDScopeDownscoping).
func validateTokenExchangeScopes(
client *Client,
requestedScopes, subjectScopes, actorScopes []string,
scopelessActorPath bool,
) ([]string, error) {
requestedScopes = normalizeRequestedScopes(requestedScopes)
// Reject a client-requested bare prefix only. Input tokens may still carry
// it (authorize accepts it); empty-request fallback inherits that ceiling.
for _, scope := range requestedScopes {
if scope == domain.OrgRoleIDScope {
return nil, oidc.ErrInvalidScope().
WithDescription("scope %q requires an organization id", scope)
}
}
if scopelessActorPath {
return validateImpersonationTokenExchangeScopes(client, requestedScopes, subjectScopes, actorScopes)
@@ -212,9 +222,11 @@ func validateTokenExchangeScopes(
// This covers standard exchange (actor == subject) and actor-path exchanges with
// access/JWT subject tokens. Actor-path exchanges with scope-less subjects
// (user_id, id_token) use validateImpersonationTokenExchangeScopes instead.
// Rule: every scope must be present on the subject or actor token (union).
// For standard exchange both lists are identical, so this is effectively
// "subset of subject token only".
//
// Non-restriction scopes must be present on subject ∪ actor. OrgRoleIDScope is
// validated separately against the subject token's filter ceiling so a broad
// subject can be narrowed, but a previously narrowed subject filter cannot be
// widened (actor filters are ignored on this path).
func validateUnionTokenExchangeScopes(
client *Client,
requestedScopes, subjectScopes, actorScopes []string,
@@ -227,12 +239,20 @@ func validateUnionTokenExchangeScopes(
}
for _, scope := range requestedScopes {
if isTokenExchangeRestrictionScope(scope) {
continue
}
if !scopeInUnion(scope, subjectScopes, actorScopes) {
return nil, oidc.ErrInvalidScope().
WithDescription("scope %q not found in subject or actor token", scope)
}
}
requestedScopes, err := applyOrgRoleIDScopeDownscoping(requestedScopes, subjectScopes)
if err != nil {
return nil, err
}
return op.ValidateAuthReqScopes(client, requestedScopes)
}
@@ -240,8 +260,9 @@ func validateUnionTokenExchangeScopes(
// cannot carry scopes (user_id, id_token). The actor token authorizes the
// request; impersonation permission is checked later in CreateOIDCSession.
//
// Two scope classes:
// Scope classes:
// - authorization scopes: privilege/audience/roles — must be on input tokens
// - restriction scopes: OrgRoleIDScope filter — downscope-only vs actor filters
// - subject-data scopes: user claims (email, profile, …) — client allowlist only
func validateImpersonationTokenExchangeScopes(
client *Client,
@@ -260,8 +281,14 @@ func validateImpersonationTokenExchangeScopes(
WithDescription("scope %q not found in subject or actor token", scope)
}
// Subject-data scopes (openid, profile, email, urn:zitadel:iam:user:*, …)
// skip the union check — validated by client allowlist below.
// Restriction and subject-data scopes skip the union check —
// restriction scopes are validated below; subject-data by client allowlist.
}
// Subject cannot carry scopes; the actor filter is the only available ceiling.
requestedScopes, err := applyOrgRoleIDScopeDownscoping(requestedScopes, actorScopes)
if err != nil {
return nil, err
}
return op.ValidateAuthReqScopes(client, requestedScopes)
@@ -278,8 +305,60 @@ func scopeInUnion(scope string, subjectScopes, actorScopes []string) bool {
return slices.Contains(subjectScopes, scope) || slices.Contains(actorScopes, scope)
}
// isTokenExchangeRestrictionScope identifies scopes that only filter token
// claims. They may be newly requested when the input tokens are unfiltered,
// but must not expand an existing filter — see applyOrgRoleIDScopeDownscoping.
func isTokenExchangeRestrictionScope(scope string) bool {
return strings.HasPrefix(scope, domain.OrgRoleIDScope)
}
// orgRoleIDScopes returns the OrgRoleIDScope entries from scopes, preserving order
// and dropping duplicates.
func orgRoleIDScopes(scopes []string) []string {
var out []string
for _, scope := range scopes {
if !isTokenExchangeRestrictionScope(scope) {
continue
}
if !slices.Contains(out, scope) {
out = append(out, scope)
}
}
return out
}
// applyOrgRoleIDScopeDownscoping enforces that OrgRoleIDScope can only narrow
// role-org filters relative to ceilingScopes (the caller's chosen ceiling):
// - union / standard exchange passes the subject token scopes
// - impersonation (scopeless subject) passes the actor token scopes
//
// Rules:
// - ceiling unfiltered → any requested filter is allowed (broad → narrow)
// - ceiling filtered → requested filters must be ⊆ that set
// - ceiling filtered but request omits the filter → inherit the ceiling
// (omitting would otherwise re-widen role claims to all granted orgs)
func applyOrgRoleIDScopeDownscoping(requested, ceilingScopes []string) ([]string, error) {
allowed := orgRoleIDScopes(ceilingScopes)
requestedFilters := orgRoleIDScopes(requested)
if len(allowed) == 0 {
return requested, nil
}
if len(requestedFilters) == 0 {
return append(slices.Clone(requested), allowed...), nil
}
for _, scope := range requestedFilters {
if !slices.Contains(allowed, scope) {
return nil, oidc.ErrInvalidScope().
WithDescription("scope %q expands org role filter beyond the permitted downscope set", scope)
}
}
return requested, nil
}
// isTokenExchangeAuthorizationScope identifies scopes that control what a token
// may do (audiences, refresh, roles), not which user claims to embed.
// Restriction scopes are intentionally excluded — see isTokenExchangeRestrictionScope.
func isTokenExchangeAuthorizationScope(scope string) bool {
switch scope {
case oidc.ScopeOfflineAccess, ScopeProjectsRoles:
@@ -297,9 +376,6 @@ func isTokenExchangeAuthorizationScope(scope string) bool {
if strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) {
return true
}
if strings.HasPrefix(scope, domain.OrgRoleIDScope) {
return true
}
if strings.HasPrefix(scope, domain.SelectIDPScope) {
return true
}
+188
View File
@@ -1,6 +1,7 @@
package oidc
import (
"slices"
"testing"
"github.com/stretchr/testify/assert"
@@ -41,6 +42,143 @@ func Test_validateTokenExchangeScopes(t *testing.T) {
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},
@@ -49,6 +187,35 @@ func Test_validateTokenExchangeScopes(t *testing.T) {
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},
@@ -102,6 +269,7 @@ func Test_isTokenExchangeAuthorizationScope(t *testing.T) {
{ScopeProjectsRoles, true},
{ScopeProjectRolePrefix + "admin", true},
{domain.ProjectIDScope + "proj" + domain.AudSuffix, true},
{domain.OrgRoleIDScope + "388047065096336384", false},
{ScopeUserMetaData, false},
}
for _, tt := range tests {
@@ -110,3 +278,23 @@ func Test_isTokenExchangeAuthorizationScope(t *testing.T) {
})
}
}
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))
})
}
}