mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-14 18:05:12 +00:00
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:
co-authored by
Cursor Agent
Livio Spring
parent
94f53145da
commit
8e442ed4bc
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user