Commit Graph
2 Commits
Author SHA1 Message Date
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
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