Commit Graph
2219 Commits
Author SHA1 Message Date
4bc484dac9 perf(eventstore): read projection events per event type (#12753)
# Which Problems Are Solved

- A projection that subscribes to several event types gets slower the
further it falls behind.
- The handler reads its events with one query: `aggregate_type =
ANY(...) AND event_type = ANY(...)`, ordered by position and limited to
the bulk limit.
- The `es_projection` index returns each (aggregate type, event type)
range in position order, but Postgres cannot merge those ranges.
- So each batch reads every matching event after the projection's
position, sorts them all, and keeps only the bulk limit.
- A projection that is N events behind therefore reads about N rows per
batch. Catching up costs about N² in total, and once events arrive
faster than such batches can process them, the projection does not
recover.
- Every query that triggers the projection waits for that catch-up.
Examples are `GetSession` and the notification handlers.

# How the Problems Are Solved

- **New opt-in, `SearchQueryBuilder.ScanEventTypesSeparately()`.** It
applies to event queries for a single instance, ascending and limited,
whose sub-queries filter only by aggregate and event types.
- For such a query, the SQL layer reads each distinct (aggregate type,
event type) combination in its own `ORDER BY … LIMIT` subquery, using
`UNNEST … CROSS JOIN LATERAL`.
  - It then merges the results with the same sort key and limit.
- Each subquery is one `es_projection` range read in position order, so
it stops at the limit. A batch reads at most `limit × combinations`
rows, however far behind the projection is.
  - Every other query renders exactly as before.
- **The projection handler** now passes its real combinations, one
sub-query per aggregate type instead of the cross product of all types,
and sets the opt-in.
- **Results are identical to the single query.**
- The sort key (`position, in_tx_order, instance_id, aggregate_type,
aggregate_id, sequence`) is a total order, and the global top n is
contained in the union of each combination's top n.
- The cursor from #12703 and the open-transaction cap apply inside every
subquery.
- A test against a real Postgres compares both shapes from every cursor
and every position, for several limits. The test events include
positions shared by several transactions, whose `in_tx_order` restarts.

# Additional Changes

This is a separate commit and can be split into its own PR:
- `cmd/defaults.yaml` sets `BulkLimit: 1000` for the `sessions`
projection.
- `projection_state_latency` gets histogram buckets up to one week.
Until now, every projection more than 30 minutes behind was reported in
the same top bucket.

# Additional Context

These numbers come from a local, CPU-pinned sandbox, for comparing
builds only.
- **Database:** Postgres 17 in a VM with 8 vCPUs and 16 GiB. The
`Eventstore.Autovacuum` and `Projections` settings mirror a
production-like setup.
- **Starting state:** `sessions8` 4.8M events behind, restored
byte-identically for every run.

Batch query for `sessions8`, `EXPLAIN ANALYZE`, median of 5 runs,
parallel workers at the default:

| | before | after |
| --- | ---: | ---: |
| at the backlog, limit 1000 | 832 ms | 4.0 ms |
| at the backlog, limit 200 | 832 ms | 1.0 ms |
| at the head (idle poll) | 0.14 ms | 0.19 ms |
| generic plan (`plan_cache_mode=force_generic_plan`), at the backlog |
1,042 ms | 4.0 ms |

Draining `sessions8` from that backlog for 600 s, 3 runs per build
(median, with the range in brackets):

| | before | after |
| --- | ---: | ---: |
| events/s | 975 (932-1,032) | 7,811 (7,523-7,955) |
| mean batch time, including writes | 1.03 s | 0.13 s |
| still behind after 600 s | 4.2M | 17k-277k |

A new index `(instance_id, position, in_tx_order, aggregate_type,
event_type)` with the old query was evaluated as an alternative and
rejected. Under a generic plan, Postgres chose it for projections whose
events are rare and walked the whole event stream: 430 ms instead of
0.15 ms. This is the behaviour that led to dropping
`es_instance_position` in #10626.

- Follow-up for #12703

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-22 17:09:42 +02:00
18a4f883f0 Merge commit from fork
* fix(login): gate username change on the change-username step

handleChangeUsername renamed the account identified by authReq.UserID
without checking that the login flow had reached the change-username
step or that any factor had succeeded. Because authReq.UserID is bound
as soon as a login name is submitted (checkLoginName -> SetUserInfo),
an unauthenticated caller could start a flow, type a victim's login
name and then POST a new username to rename that account, including
instance admins.

Gate the handler on the current step being *domain.ChangeUsernameStep,
mirroring the other login handlers. That step is only produced in
nextSteps after the first factor and any required MFA have succeeded,
so the gate also proves the caller owns the account. The legitimate
forced-username-change flow is unaffected: the UI only ever renders
that form when the step is at index 0, and PossibleSteps is recomputed
on every request.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Aup17GXd4tQnXG36xr5UiZ

* test(login): add integration reproduction for GHSA-4hgj-wm6c-q7p2

Drive the unauthenticated-rename attack over raw HTTP against a running
instance, mirroring the sibling GHSA-738m external-login integration
test in the same package: authorize (V1) -> load login page (scrape
CSRF) -> POST the victim's login name (binds authReq.UserID, no factor)
-> POST a new username.

The test first proves the exploit precondition held - step 3 advanced
the flow to the victim's password step, so authReq.UserID was bound -
then asserts over gRPC that the victim's username is unchanged. The
precondition check guards against a false green: without it, a broken
upstream hop would make the unchanged-username assertion pass vacuously
even against unpatched code.

The unchanged-username poll and the sibling test's requireNeverUserByEmail
now share a small pollFor helper in the package.

Red -> green: fails against the unpatched handler (the rename lands),
passes against the fixed handler (the ChangeUsernameStep gate rejects
the POST before the command runs). Runs in the existing
nx test-integration job via the integration build tag.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Aup17GXd4tQnXG36xr5UiZ

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-09-21 13:37:27 +02:00
3ff4fbc7e4 fix(eventstore): add owners to unique constraints (#12740)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# Which Problems Are Solved

Bulk unique-constraint deletes (org, IDP with many links, user, project)
reconstruct every uniqueness key in Go and emit N `DELETE`s. That can
OOM on large tenants.

A one-shot owner backfill is also not enough: during a rolling deploy,
older binaries still insert `owners = '{}'`, so a later owner-only
delete would miss those rows.

Owner-only org/user/grant delete only matches rows whose `owners` bag
contains that tag. Several grant and member types stamped the aggregate
resource owner, which is not always the user’s home org or the granted
org. Username-scope rewrites also dropped `user:` tags, so owner-only
user delete could miss rewritten username rows.

Command must not switch to owner-only delete on the first 79 of a
version, because leftover `'{}'` rows from rolling-update overlap are
only backfilled on a later 79. A failed 79 must not turn owner-delete
on, and backfill joins must not stamp the wrong org (unverified domains,
org-scoped usernames colliding with instance-scoped ones).

# How the Problems Are Solved

Uniqueness stays the existing primary key `(instance_id, unique_type,
unique_field)`. `owners` is only a lifecycle bag of tags (`org:<id>`,
`user:<id>`, `idp:<id>`, `project:<id>`, `grant:<id>`).

- Add `owners TEXT[] NOT NULL DEFAULT '{}'` on
`eventstore.unique_constraints` (init SQL for greenfield, setup 78 for
existing clusters).
- Create `unique_constraints_owners_gin` with `CREATE INDEX
CONCURRENTLY` **outside a transaction** (same pattern as step 76).
Built-in `GIN (owners)` only; no `btree_gin`.
- Stamp `WithOwners` on **add** events. Field-level removes ignore
owners. `RemoveByOwner` stores the tag in `Owners` and both v1/v3 and v2
pushers run `DELETE … WHERE instance_id = $1 AND owners @>
ARRAY[$tag]::text[]`.
- Setup 79 backfills `owners` from projections via numbered
`cmd/setup/79/*.sql` files. Table names are interpolated from
`internal/query/projection` constants. Matches use `unique_field IN (x,
lower(x))` so the primary key can be used. Missing projection tables
(`42P01`) are skipped.
- Org domains are tagged only when `is_verified`. Instance-scoped
usernames join only users that are not org-scoped (organization settings
or domain policy `user_login_must_be_domain`). Every username row still
gets `org:` + `user:` of that user.
- 79 is a **repeatable** migration. `Check` re-runs when
`lastRun["version"]` changed or when setup-only `ForceFinalize` is set
and `lastRun["finalized"]` is still false. `Finalized` is written only
after Execute succeeds.
- Failed setup events keep the last successful `lastRun`. Command reads
`finalized` only from `system.migration.repeatable.done`.
- Org / user / project remove skip N-key event replay **when that
`finalized` bit is true**. Otherwise they keep today’s N-key lists
**and** still emit `RemoveByOwner`. The “ready” result is cached in
memory once true.

Setup writes `finalized` on the 79 repeatable.done payload:

- First 79 of a version: backfill only, `finalized=false` (unless
`ForceFinalize` is already set).
- Later 79: version change or
`BackfillUniqueConstraintOwners.ForceFinalize` /
`ZITADEL_BACKFILLUNIQUECONSTRAINTOWNERS_FORCEFINALIZE` after the rolling
update is done. That run backfills leftovers and writes
`finalized=true`.

Member and grant owner bags now include every org that should delete the
row:

- Instance, org, and project members also stamp
`org:<user_resource_owner>`.
- User grants stamp grant, user, project, and granted orgs.
- Project grant members stamp the project org, user org, and granted
org.
- Project grants stamp both orgs plus `grant:<id>`. Grant remove emits
`RemoveByOwner(grant)`.
- Username-scope rewrites keep `user:<id>` as well as `org:`.
- User/grant orgs are carried as unexported add-event fields (not
persisted JSON). `WithOwners` skips empty and duplicate tags.

# Additional Changes

- Table-driven owner-tag tests, including project grant (two `org:` tags
plus `grant:`), cross-org members, user grants, and username-scope
rewrite `user:` tags.
- Table-driven 79 `Check` + `finalizeAfterSuccess` tests for version
change, sequential version bumps, ForceFinalize restart, and
already-finalized skip.
- Command tests: a failed 79 event with `finalized=true` does not enable
owner-delete.
- Integration fixtures (verified vs unverified org domain;
instance-scoped `bob` vs org-scoped `bob`).
- Instance-only and global unique types stay untagged (`'{}'`). Instance
wipe still uses `UniqueConstraintInstanceRemove`.
- Pusher owner cases live in `eventstore_pusher_owners_test.go` so
`eventstore_pusher_test.go` stays under 1k lines.

# Additional Context

A later cleanup can delete the N-key replay helpers once a release that
ran 79 a second time (or an operator who set `ForceFinalize`) is in
production. That is not required for correctness.

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-44f9bc9b-9a93-4cd9-8dd1-663a7b9aad2a?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-44f9bc9b-9a93-4cd9-8dd1-663a7b9aad2a&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Silvan <[email protected]>
2026-09-18 15:08:42 +02:00
Tim MöhlmannandGitHub 73de633c71 feat(database): publish pgxpool pool stats as metrics (#12737)
# Which Problems Are Solved

ZITADEL opens its Postgres connection pool via `pgxpool.NewWithConfig`
(`internal/database/postgres/pg.go`) but never reads
`pgxpool.Pool.Stat()`,
so operators have no way to observe pool saturation (acquired/idle/total
conns vs. `MaxConns`) or connection churn (acquire waits, canceled/empty
acquires, idle/lifetime destroys, new connections) through ZITADEL's
existing metrics pipeline.

# How the Problems Are Solved

- Adds `RegisterCounterObserver` (backed by an OTel
`Int64ObservableCounter`)
  to the `Metrics` interface, its implementation, and the test mock
  (`backend/v3/instrumentation/metric.go`,
  `backend/v3/instrumentation/metrics/metric.go`,
  `backend/v3/instrumentation/metrics/mock.go`), mirroring the existing
`RegisterValueObserver`/`RegisterUpDownSumObserver` — there was
previously
  no observable instrument for cumulative counters.
- Adds `database.RegisterPoolMetrics(ctx, pool)`
  (`internal/database/metrics.go`), which registers 5 observable gauges
  (`acquired_conns`, `constructing_conns`, `idle_conns`, `max_conns`,
  `total_conns`) and 7 observable counters (`acquire_count`,
  `acquire_duration_milliseconds`, `canceled_acquire_count`,
  `empty_acquire_count`, `max_idle_destroy_count`,
  `max_lifetime_destroy_count`, `new_conns_count`) sourced directly from
`pgxpool.Pool.Stat()`, published under `zitadel.database.pool.*` metric
  names.
- Wires the registration call into `cmd/start/start.go` right after the
  pool is created, so the running server process exposes these series on
  `/metrics`.

# Additional Changes

- Adds unit tests covering the new `RegisterCounterObserver` method
  (`backend/v3/instrumentation/metric_test.go`) and the pool metrics
  registration (`internal/database/metrics_test.go`).

# Additional Context

- Scope is intentionally limited to `cmd/start` (the long-running
process
  that serves `/metrics`); one-shot commands (`setup`, `mirror`,
`initialise`, `key`) are left untouched since an exporter running for a
  single command invocation wouldn't produce useful series.
- https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool#Stat
2026-09-15 12:13:30 +02:00
fbe7b96138 fix(eventstore): prevent projections from skipping events (#12703)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# Which Problems Are Solved

Handler/v2 projections (`users14` and every other event projection) can
overlook committed events under load:

- `eventstore.commands_to_events` stamped `position` with `NOW()`
(transaction start). A slow create TX could commit after a faster TX
that started later, so the projector stored a later position and never
saw the earlier one.
- Resume used `OFFSET` on a **filtered** row count. That could skip
`in_tx_order = 1` at the next position (for example `user.human.added`).
- Two transactions in the same microsecond both start at `in_tx_order =
1`. A cursor of `in_tx_order > last` alone would drop the other TX’s
first event.

This does not repair rows that are already missing from projections.

# How the Problems Are Solved

- Setup step 77 replaces `eventstore.commands_to_events` so `created_at
= statement_timestamp()` and `position = EXTRACT(EPOCH FROM
clock_timestamp())` at INSERT time. Initialise `08_events_table.sql`
matches that for new databases. The function is `VOLATILE` to match
`clock_timestamp()`.
- The projector still drains in-flight writers with `pg_advisory_lock` /
`pg_advisory_unlock` in one statement, then caps with `position <=
EXTRACT(EPOCH FROM now())` (projector TX start). The exclusive lock is
**not** held across the event `SELECT`. The cap must stay `now()`, not
`clock_timestamp()`, or later inserts become a high-water mark.
- Resume is a lexicographic `EventSortKey` on `(position, in_tx_order,
instance_id, aggregate_type, aggregate_id, sequence)` via
`AfterEventSortKey`. SQL `OFFSET` is no longer used for projection
catch-up.
- Handler and FieldHandler persist the last event’s `in_tx_order` in
`current_states.filter_offset` (existing column, new meaning). Step 77
backfills matching rows from `events2`; unmatched rows keep their
previous value.

# Additional Changes

- `eventstore.Event` exposes `InTxOrder() uint32` (BaseEvent, repository
Event, v3 event, v1 models).
- v3 push SELECT/RETURNING now includes `in_tx_order` so newly written
events carry a real ordinal.
- `WithMinPosition` stays an inclusive `PositionAtLeast` floor and does
not smash the sort-key cursor.
- `SearchQuery.PositionAfter(decimal)` is unchanged (used by
access-token lookup). Do not confuse it with
`SearchQueryBuilder.AfterEventSortKey`.
- `commands_to_events` in setup 77 and initialise 08 is `VOLATILE
PARALLEL SAFE`. Shipped 40/64/70 stay `STABLE` because they still stamp
with `NOW()`.
- Drop unused `last_owner TEXT` DECLARE from setup 77 and initialise
`08_events_table.sql` (`i INTEGER` remains for the enforce-owner loop).
Shipped 40/64/70 keep the leftover.
- This PR’s migration is numbered 77 because `main` already has setup 76
(`76_users14_login_equality_indexes`). First-loop order is s70, then
main’s 76, then this 77.

# Additional Context

**Reviewer map**

| Start here | Why |
| --- | --- |
| `cmd/setup/77.sql` + `cmd/setup/77.go` | Position stamp + atomic
function replace/backfill after shipped `s70` and main’s s76 |
| `internal/eventstore/event_sort_key.go` + `search_query.go` |
Canonical cursor and `AfterEventSortKey` |
| `internal/eventstore/handler/v2/{state.go,handler.go}` |
Persist/resume cursor; `eventQuery` |
| `internal/eventstore/repository/sql/{query.go,postgres.go}` | Tuple
`>` filter, 6-column `ORDER BY`, await cap |
| `internal/eventstore/v3/push.go` / `push.sql` | Scan `in_tx_order` on
write |

Mechanical / low-risk: handler and query test fixtures, Event interface
stubs (`event_base.go`, repository Event, mocks, v1 models).

**Do not look for** a `70.sql` change — shipped migrations stay as on
`main`. Setup 40/64 still use `NOW()`; step 77 overwrites the function
afterward. Fresh `FirstInstance` (runs before `s70`) can still stamp
`NOW()` until 77; runtime pushes after 77 use `clock_timestamp()`.
Main’s `cmd/setup/76.go` is the users14 login-equality indexes, not this
stamp.

**Rollout**

1. Run setup so 77 replaces `commands_to_events` and backfills
`filter_offset`.
2. Restart ZITADEL so projectors load the new resume path.
3. Existing missing `users14` rows are not repaired by this PR.

**Tests**

- `go test ./internal/eventstore/... ./cmd/setup/`
- Sort-key SQL fixtures in `query_sort_key_test.go` (no `OFFSET`;
aggregate-id queries keep 6-column `ORDER BY`)
- `cmd/setup/77_test.go` asserts the embed has no unused `last_owner`
and still uses `clock_timestamp()` / `VOLATILE PARALLEL SAFE`

Related: #8509, #10480 (remove OFFSET), #10560 (advisory locks), #10710
(stable ORDER BY).
Out of scope: repairing already-missing projection rows, holding the
exclusive lock during SELECT, rewriting historical setup 40/64 SQL.
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-3da6eb51-11d4-4a8a-8ba0-e190f116df05?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-3da6eb51-11d4-4a8a-8ba0-e190f116df05&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Silvan <[email protected]>
2026-09-09 11:08:50 +02:00
684de2e691 perf(query): use UNION of index seeks for ListUsers login equality (#12701)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# Which Problems Are Solved

Login V2 `ListUsers` lookups of the form username OR email (and email OR
phone) hash-join the full `users14` / `users14_humans` tables, then
filter. That cost grows with instance size (~40ms at 10k users, ~650ms
at 200k).

Top-level login-name equals already used an indexed join, but the same
login-name filter inside an `OrQuery` fell back to the slow view-based
exists query.

# How the Problems Are Solved

**TL;DR:** equality lookups on username, email, phone, or login name
become `INNER JOIN (UNION of indexed ID seeks) AS matches`. Mixed /
contains / name searches keep the old WHERE shape.

### Reviewer map

| Read first | Why |
| --- | --- |
| `internal/query/user_login_equality.go` | Typed filters + extractor +
UNION join |
| `internal/query/user.go` (`prepareUsersQuery`, constructors) | Wiring;
username/email/phone equals return `lowerEqualsFilter` |
| `cmd/setup/76.go` + `cmd/setup/76/*.sql` | Concurrent indexes for
existing deployments; drop invalid leftovers |
| `internal/query/projection/user.go` | Same indexes in `Init()` for new
databases |
| `internal/query/user_login_name_exists_test.go` | SQL-shape coverage |
| `internal/api/grpc/user/v2/integration_test/query_test.go` | Nested
username AND/OR user results |

### Query rewrite

- Extract one equals leaf, a flat `OrQuery` of those leaves, or the same
nested under `AndQuery`. Remaining AND conjuncts stay as WHERE. Equality
nested under `OrQuery` / `NotQuery` is left as WHERE.
- Login-name equals is a UNION arm that reuses `login_names3_users` SQL,
so it works inside OR as well as at the top level.
- EQUALS seeks use `LOWER(col)` for the index, then recheck `col =
value` so case-sensitive searches do not match extra rows.
EQUALS_IGNORE_CASE is unchanged.
- Empty OR arms are not skipped (that would change OR meaning); mixed
empty-OR falls back to WHERE. Login UI already omits empty arms.

### Indexes

- Setup 76: `CREATE INDEX CONCURRENTLY IF NOT EXISTS` on
`users14_username_lower_idx` `(instance_id, LOWER(username))` and
`users14_humans_phone_lower_idx` `(instance_id, LOWER(phone))`. Not
wrapped in a transaction.
- Before create, drop any leftover `pg_index.indisvalid = false` indexes
of those names (failed/cancelled `CREATE INDEX CONCURRENTLY`). `IF NOT
EXISTS` would otherwise skip rebuild.
- `Init()` creates the same indexes without `CONCURRENTLY` (empty
tables). Existing `users14_username` and email lower indexes are
unchanged.

# Additional Changes

- Unit tests assert UNION SQL for username OR email, email OR phone,
login name OR email, username OR username, nested `AndQuery(loginName,
org)`, and nested `AndQuery(Or(...), org)`; that contains / display-name
/ mixed OR / nested `OrQuery(AndQuery(...), ...)` do not UNION; that
email EQUALS rechecks the raw column; and that empty-OR is not
extracted.
- Setup 76 sqlmock coverage: missing `users14` skips work; valid/absent
indexes only create; invalid leftover username index is dropped then
both creates run.
- `TestServer_ListUsers_UsernameAndOr` checks live results: org AND
(alice OR bob) returns both users, sibling username AND is empty, nested
`AndQuery(Or(...), org)` returns both.

# Additional Context

- Base branch: `main`
- Extends the login-name equals rewrite from #12460 to
username/email/phone OR trees used by Login V2 search.

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-1f043b1c-6168-45ca-9e9e-c18451994207?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-1f043b1c-6168-45ca-9e9e-c18451994207&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Silvan <[email protected]>
2026-09-08 13:11:16 +00:00
9067605b66 Merge commit from fork
* fix(crypto): use authenticated encryption for IDP intent tokens

IDP intent tokens were encrypted with AES-CFB which provides no
integrity protection, making them malleable. Switch to A256GCM via
the existing AuthAlgorithm interface (EncryptToken/DecryptToken),
the same approach already used for OIDC and session tokens.

The idpConfigEncryption key type is widened from EncryptionAlgorithm
to AuthEncryptionAlgorithm so that data-at-rest encryption (IDP
client secrets, LDAP bind passwords, SAML keys) continues to use
the CFB path unmodified.

Co-authored-by: Cursor <[email protected]>

* fix(proto): raise IDP intent token max length to 500 for authenticated tokens

The switch to authenticated A256GCM (JWE compact serialization) makes IDP
intent tokens longer (~270 chars) than the previous AES-CFB base64 output.
Raise the max_len/max_length validation and OpenAPI constraints on every
idp_intent_token field from 200 to 500 so the new tokens are accepted:
user v2/v2beta (idp + user_service), session v2/v2beta, and resources
user v3alpha.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-09-04 06:13:37 +02:00
f3dcfa3db6 Merge commit from fork
* fix(security): check auth method permission on the user's organization

ListUserAuthMethodTypes checked user.read against the caller's own
organization instead of the organization owning the requested user. Any
org level role holding user.read - ORG_OWNER, ORG_OWNER_VIEWER - therefore
passed the check for every user of the instance, and the query itself
filters on instance and user id only, so it returned that user's
authentication methods across the organization boundary.

Resolve the requested user first and check the permission on its resource
owner, the same resolution GetUserByIDWithPermission already uses.

This covers both the v2 and v2beta ListAuthenticationMethodTypes handlers,
which share this function. The two login UI callers scope the context to
the user itself, so they keep skipping the check and pay no extra query.

Note that a request for a non existing user id now returns NotFound
instead of an empty list, which also removes an existence oracle.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MeUQhL2hxvnYbPn3qhWJAL

* test(user/v2): cover cross-organization auth method enumeration

TestServer_ListAuthenticationMethodTypes_CrossOrg fails without the
preceding fix: an ORG_OWNER and an ORG_OWNER_VIEWER of one organization
could list the authentication methods of a user of another organization,
leaking AUTHENTICATION_METHOD_TYPE_PASSWORD.

The two own-org subtests are positive controls, not decoration.
Memberships and auth methods are projected asynchronously, so without them
a denial could pass for the wrong reason - a membership that has not been
projected yet. The IAM_USER_MANAGER subtest pins that instance wide
user.read keeps working across organizations, so the fix narrows the
organization without narrowing the role set.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MeUQhL2hxvnYbPn3qhWJAL

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-04 05:57:02 +02:00
dbbab75420 Merge commit from fork
* fix(security): prevent unauthenticated IDP-linking account takeover

Two distinct defects allowed an attacker to bind their own external
identity to a victim's account and, because external IDP links are
globally unique (first claim wins), hijack the victim's future
federated logins.

LoginV2: resolveUserIdFromSession returned the session's user id after
only a null check, trusting an "identify-only" session (login name
submitted, no verified primary factor). An unauthenticated attacker who
knew a victim's login name could drive explicit linking against that
session. Gate it on isSessionValid so only a fully authenticated
session can link.

Backend: AddUserIDPLink skipped the user-write permission check when the
caller targeted their own account (userID == ctx user). Because the
external subject is caller-supplied and unverified, this let any
authenticated user self-link an unproven identity. Make the permission
check unconditional. The login/federation flow uses a service account
(userID != ctx user) and already took the permission branch, so it is
unaffected; verified self-service linking goes through the IDP-intent
flow.

Adds a LoginV2 regression test (identify-only session is rejected) and
the first command-level test for AddUserIDPLink (self-link without
permission is denied).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* test(user_idp_link): address review on AddUserIDPLink hardening

- add success regression cases for AddUserIDPLink (self-link with
  permission, other-user link with permission) so the login-client and
  managed-admin paths are pinned and the assert.NoError branch is live
- add an integration case: UserCTX self-linking on its own account is
  now denied
- document the intentional no-permission-check invariant on
  BulkAddedUserIDPLinks (verified Login V1 flow only; not API-exposed)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RUhwzSqHhycHNAJEfMcrdd

* fix(login): authorize IDP linking via enrollment guard, not isSessionValid

Replace the isSessionValid gate in resolveUserIdFromSession with
getEnrollmentAuthorizationError. isSessionValid requires a verified
primary factor, which rejected the legitimate "link an IDP as the first
authenticator" onboarding flow (a freshly verified user with no
credentials yet). The enrollment guard authorizes when the session has a
verified, non-expired primary factor OR the user has zero auth methods
and passed the user-verification check (email/invite-code proof bound to
the browser fingerprint) — closing the identify-only ATO while preserving
first-authenticator linking.

Replace the single mocked rejection test with four that run the real
guard: identify-only + has credentials -> rejected; identify-only + no
credentials + no verification check -> rejected; expired primary factor
-> rejected; identify-only + no credentials + verification check ->
allowed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01T24WwsRLbaC7aHxBmyznnd

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-09-04 05:48:51 +02:00
Matías RacedoandGitHub 78be618357 fix(login): translate authentication and OTP strings (#12668)
Closes #12481
Closes #12226

# Which Problems Are Solved

Login V2 still contained several hardcoded authentication-method and OTP
strings, preventing them from following the selected locale or being
overridden through custom Login translations.

Additionally, the password-reset flow already referenced
`password.errors.passwordResetNotAllowed`, but the key was missing from
the Login V2 translations, causing the raw translation key to be
displayed when password reset is disabled.

# How the Problems Are Solved

* Replaced the hardcoded authentication-method labels with Login V2
translation keys.
* Replaced the OTP challenge and verification fallback messages with
translated values.
* Reused the existing `otp.verify.resendCode` translation for the resend
button's accessible label.
* Added `password.errors.passwordResetNotAllowed`.
* Added the new keys to all supported Login V2 locales and mirrored them
in `internal/query/v2-default.json` so frontend and API-provided default
translations remain consistent.
* Added unit coverage for the translated authentication labels and OTP
failure paths.

# How the Changes Are Tested

* `pnpm nx run @zitadel/login:test-unit`
* `pnpm nx run @zitadel/login:lint`
* `pnpm nx run @zitadel/login:build`
* `pnpm nx run-many --projects @zitadel/login @zitadel/client
@zitadel/proto --targets lint build test`
* Manually verified Login V2 with multiple locales, including
authentication-method labels, OTP failure fallbacks, and the disabled
password-reset flow.
2026-09-03 06:01:34 +02:00
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
711d688294 fix(login): resolve the registration org consistently with the login policy (#12621)
# Which Problems Are Solved

When resolving the organization for a self-registration, the resource
owner was determined only from an explicitly requested org, falling back
to the default org. The login policy applied to the same request,
however, is resolved from the requested org, the user org, and only then
the default org. As a result the two could diverge — e.g. after the
login context had already resolved a user's organization — and a user
could be registered in a different organization than the one whose
settings were evaluated for the request.

# How the Problems Are Solved

`determineResourceOwner` now uses the same resolution order as the
policy lookup (requested org, then user org, then default org), so the
organization a user is registered in matches the one the request was
evaluated against.

# Additional Changes

Added a unit test covering the resolution order.

# Additional Context

Aligns login v1 registration org resolution with the behavior in login
v2.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-28 10:37:52 +00:00
6128aeaa89 fix(security): prevent TOTP reuse (#12616)
# Which Problems Are Solved

TOTP codes could be reused, violating [RFC 6238, section
5.2](https://datatracker.ietf.org/doc/html/rfc6238#section-5.2).

# How the Problems Are Solved

Transmitted codes are now recorded in each success event. As the codes
amount to secrets, they are not stored verbatim but as a randomly salted
HMAC-SHA256 (`crypto.HMACValue`). Each value gets a fresh random salt,
so an equal code always produces a different stored value and two
entries can never be recognized as being derived from the same code.

This is deliberately not a replacement for proper secret storage: with
the salt stored alongside the hash and a 6 digit payload, anyone with
event access can brute-force the code in milliseconds. That is
acceptable here because a code is worthless once its window has passed.
The `HMACValue` doc comment states the same.

TOTP codes are low-entropy (6 digits), pseudo random codes. Therefore it
is possible for the same code to be generated more than once over the
lifetime of a user. In order to prevent the (unlikely) case where users
login with large time intervalls and a generated code may be equal to
the one last used a long time ago, we only check for codes used within
the recent period window.

A simpler implementation would be simply storing the latest consumed
step, but the TOTP library does not return this value. Because of the
allowed skew there are always 3 steps within the validation window, so
we can't calculate the current step based on time alone. The retained
history spans one period more than the validation window: validity is
decided by the app server's clock, while the stored events carry the
database clock, and the extra period absorbs the drift between them.

**Note:** a reused code pushes a distinct
`user.human.mfa.otp.check.reused` event, not a failed check event. A
replay therefore stays observable through the Events API without
counting towards the lockout policy. Otherwise anyone who captured a
code could replay it to lock the user out.

# Additional Changes

- Read only the latest events in recheck query

# Additional Context

- Closes https://github.com/zitadel/zitadel/issues/6452

---------

Co-authored-by: Livio Spring <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: muhlemmer <[email protected]>
2026-08-27 07:17:39 +02:00
c9d747d407 fix(api): prevent cross-instance caching of well-known app link files (#12644)
# Which Problems Are Solved

The `/.well-known/apple-app-site-association` and
`/.well-known/assetlinks.json` responses are instance specific: the body
depends on the requested host. The responses did not declare this, so a
shared cache whose cache key does not include the host could store one
instance's file and serve it on another instance's domain. This could
result in a wrong or empty file being returned intermittently, breaking
native passkey verification.

Dropping the `public` directive (#12634) was not sufficient: depending
on its cache mode, a shared cache may store any response carrying a
`max-age`.

The docs page for native app links also still documented the old
`Cache-Control: public` header and only the single `get_login_creds`
relation (missing `handle_all_urls`, added in #12634).

# How the Problems Are Solved

- Send **`Vary: Host`** on both well-known responses, declaring that the
body depends on the host: a spec-compliant shared cache must store
per-host variants or skip caching. Clients addressing a single host (the
Apple and Google verifiers, browsers) are unaffected and keep honoring
the configured `max-age`.
- Correct the outdated caching reasoning in the code comments.
- Guard the header with unit and integration tests.
- Update the native app links docs page: both Digital Asset Links
relations and the current response headers.

# Additional Changes

None. `WellKnown.AppLinksCacheControlMaxAge` behavior is unchanged (`0`
still results in `no-store`).

# Additional Context

- Follow-up to #12634; relates to #12497.
- Verified with interleaved requests against two instances: responses
are no longer reused across domains, and each domain consistently serves
its own configuration.
- To be cherry-picked to `v4.x` from `main` once merged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-26 10:57:28 +02:00
3c3bd2bc57 fix(api): correct caching and relations of well-known app link files (#12634)
# Which Problems Are Solved

Two issues with the native app link well-known files reported after the
v4.17.0 rollout:

**1. `assetlinks.json` intermittently returned an empty `[]`** on
ZITADEL Cloud without any configuration change, across all of a
customer's environments, breaking native passkey verification. The
configuration itself was stored correctly.

The responses were served with `Cache-Control: public, max-age=<n>`. The
`public` directive opts the response into shared caches: the CDN in
front of ZITADEL Cloud is configured to cache only responses that
explicitly set `public` or `s-maxage`. That cache is a fleet of
independent edge nodes, each holding its own copy. A single momentarily
inconsistent origin response (e.g. an empty file) gets pinned on the
edge that captured it for the whole `max-age` and served to every client
routed there, while other edges serve the correct file — producing the
observed flip-flop. This is confirmed by the JWKS endpoint, which serves
`max-age=<n>, must-revalidate` **without** `public`, has never been
cached by the CDN, and has never shown this inconsistency.

**2. Passkey creation failed on Google Pixel devices** while working on
iOS and other Android devices. Google's [Credential Manager
prerequisites](https://developer.android.com/identity/credential-manager/prerequisites)
require the Digital Asset Links statement for passkeys to declare
**both** `delegate_permission/common.handle_all_urls` and
`delegate_permission/common.get_login_creds`; ZITADEL only served
`get_login_creds`, and Pixel devices enforce the documented contract
strictly.

# How the Problems Are Solved

- Drop `public` and mirror the JWKS endpoint: `Cache-Control:
max-age=<n>, must-revalidate`. Clients (the Apple and Google verifiers,
browsers) still cache the files for `max-age`; the origin stays the
single source of truth. The load profile matches JWKS, which already
serves every request from origin at far higher volume, so this does not
introduce a thundering-herd risk. A test guard asserts `public` is never
emitted.
- Emit both required relations in `/.well-known/assetlinks.json`,
matching Google's documented example. The additional relation only
permits the app to handle links for the domain; it has no effect unless
the app itself declares matching intent filters, and the entry is
configured by the app owner.

# Additional Changes

None. `AppLinksCacheControlMaxAge` and its `0 → no-store` behaviour are
unchanged.

# Additional Context

- Introduced with the native app links feature (#12580 / #12497).
- Immediate relief for the caching issue was applied by invalidating the
CDN cache for both paths; this change prevents recurrence.
- To be cherry-picked to `v4.x` from `main` once merged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-08-24 15:25:39 +02:00
Livio SpringandGitHub d9a063ba5c chore: fix integration test (#12595)
A recent commit added an a faulty integration test. This PR fixes the
corresponding test.
2026-08-14 08:35:17 +02:00
260446f91f Merge commit from fork
* fix: block Login V2 auth for users in deactivated organizations

Enforce organization state on session creation, OIDC token issuance/refresh,
and SAML session creation so deactivated org users cannot authenticate.

Co-authored-by: Cursor <[email protected]>

* fix: reject claim serving for users in deactivated organizations

Require an active resource-owner org in OIDC userinfo and SAML attribute
paths, and use distinct error IDs for inactive user vs inactive org at
token issuance.

Co-authored-by: Cursor <[email protected]>

* fix: drop sessions and tokens when an organization is deactivated

Mirror OrgRemoved cleanup for OrgDeactivated in the V2 session projection
and V1 auth user_session, token, and refresh_token handlers. Also delete
V2 sessions on OrgRemoved, which does not emit per-user removal events.

Co-authored-by: Cursor <[email protected]>

* fix: reject API tokens from deactivated organizations

Check the caller's resource-owner org state in authz middleware via a
cached OrgByID lookup, so already-issued tokens lose ZITADEL API access
when their organization is deactivated. Return unauthenticated (401).
Target org remains unrestricted so instance admins can still manage
deactivated orgs.

Co-authored-by: Cursor <[email protected]>

* fix: invalidate OIDC refresh tokens after org deactivation

Reject refresh exchange if the user's organization was deactivated
after the refresh token was issued, so grants stay dead after reactivate.

Co-authored-by: Cursor <[email protected]>

* fix: invalidate V2 access tokens after org deactivation

Treat OrgDeactivated after the token position as session termination in
ActiveAccessTokenByToken, so issued ATs stay dead after reactivation.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-08-14 06:12:45 +02:00
522b0ed7ed Merge commit from fork
The Login V1 MFA init/enrollment handlers acted on authReq.UserID via
setUserContext without verifying the auth request had completed its
first factor. A request that only submitted a loginname could enroll
TOTP/SMS/Email/U2F factors and overwrite the victim's phone number, and
the discrepant errors bypassed IgnoreUnknownUsernames.

Gate the enrollment handlers on PossibleSteps[0] == *domain.MFAPromptStep,
which nextSteps only yields after firstFactorChecked passes.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-14 06:12:04 +02:00
8a61aa4f36 Merge commit from fork
* fix(command): enforce permission check when issuing passkey enrollment codes

addUserPasskeyCode - behind CreatePasskeyRegistrationLink via
AddUserPasskeyCode / AddUserPasskeyCodeURLTemplate / AddUserPasskeyCodeReturn -
issued a passkey enrollment code without any authorization check.

The RPC's auth annotation carries user.passkey.write with no org_field, so the
API interceptor only verifies the caller's permission in the org taken from the
caller-supplied x-zitadel-orgid header, never the org that owns the target user.
The gRPC handlers additionally pass an empty resourceOwner, and the write model
lookup is instance-wide, so the code was minted against the victim's aggregate
regardless of org. An org owner of one organization could therefore issue a
passkey enrollment code for a user in another organization, enroll an
attacker-controlled authenticator and take over the account.

Authorize against the target user's actual resource owner, resolved from the
write model, before pushing the code requested event.

The check uses user.passkey.write - the permission the RPC already declares -
rather than user.credential.write used by the sibling RegisterUserPasskey. This
narrows the organization the permission is evaluated in without narrowing the
set of roles that may call the endpoint: IAM_USER_MANAGER holds
user.passkey.write but not user.credential.write, and would otherwise lose the
endpoint instance-wide.

The enrollment code stays a bearer credential by design, so the unauthenticated
login flows redeeming it are unaffected; issuance is the only place this can be
enforced.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(command): guard cross-org passkey enrollment code issuance

The existing AddUserPasskeyCode* tests all use an allowed permission check, so
none of them fails if the authorization is removed again.

Assert the denial path instead: with the caller passing an empty resourceOwner,
as the v2 gRPC handlers do, the check must receive the target user's real
resource owner resolved from the write model, and no event may be pushed.

Without the check in addUserPasskeyCode all three subtests fail, one of them on
an unexpected Push - a code would actually have been issued.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(user/v2): simulate cross-org passkey registration link attack

End-to-end reproduction against a live instance: an org owner of one
organization requests a passkey registration link, with the request-header org
pinned to its own org, for a user that lives in another organization. Uses the
return-code medium so a successful response would carry the plaintext code,
making a missing denial impossible to pass silently.

The second subtest pins the other half of the contract: IAM_USER_MANAGER holds
user.passkey.write instance-wide and must keep working across organizations, so
the fix cannot be tightened into user.credential.write without regressing it.

No equivalent test for user/v2beta: it shares the same internal/command layer
and is fixed by the same change, but the API is deprecated, so the duplicate is
deliberately omitted.

Co-Authored-By: Claude Opus 5 <[email protected]>

* refactor(command): generate passkey code only after the permission check

Review feedback: the code ID and the passkey code - the latter costs an extra
eventstore filter for the secret generator config - were produced before the
permission check, so an unauthorized caller still paid for both.

The write model is now built with an empty code ID, which is equivalent for
the lookup: the ID only matches events of an already existing code, and a code
about to be created has none. It is assigned right after the check so the
pushed event is still appended to the write model.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(command): enforce permission check on v1 passwordless init codes

The deprecated management RPCs AddPasswordlessRegistration and
SendPasswordlessRegistration accept an attacker-controlled user_id and pass the
caller's own organization to the command. That organization only scopes the
write model's read, not the write: for a user of another organization the read
matches nothing, the write model keeps the caller's organization, and on push
the eventstore re-owns the event to the target's real organization
(internal/eventstore/v3/sequence.go). An org admin could therefore mint a
passwordless enrollment link for a user of any other organization and, after
correcting the orgID in the returned link, take the account over.

Same class of flaw as the v2 CreatePasskeyRegistrationLink issue, but a
different command, so the earlier fix does not reach it. Copying that fix
verbatim would not close it either: authorizing against the write model's
resource owner would authorize against the attacker's own organization, where
they legitimately hold the permission. The check is instead handed an empty
resource owner, which makes it resolve the target's real owner from the
eventstore.

The check sits in the two exported commands rather than in the shared inner
function, for two reasons:

  - the RPCs declare different permissions, user.credential.write and
    user.write. Hardcoding one would either lock IAM_USER_MANAGER and
    ORG_USER_MANAGER out of SendPasswordlessRegistration or widen
    AddPasswordlessRegistration to them. Access is preserved exactly.
  - the inner function is also called by importHuman while creating a user,
    whose aggregate has no events yet and whose owner cannot be resolved.

Self management stays allowed, so the auth API's AddMyPasswordlessLink and
SendMyPasswordlessLink are unaffected, as is anonymous redemption of the code
through the login UI - the init code is a bearer credential by design and
issuance is the only place this can be enforced.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(command): guard cross-org v1 passwordless init code issuance

Asserts the organization handed to checkPermission, not merely that a denial
happened. A caller acting in org1 targeting a user of org2 must be authorized
against org2; a test that only asserted PermissionDenied would pass against the
attacker's own organization, where they hold the permission - which is exactly
how a fix copied from the v2 command would fail.

Also pins the permission per command, so a later attempt to hoist the check
into the shared inner function shows up as roles gaining or losing access, and
asserts that self management still skips the check for the auth API.

Negative controls, both confirmed:
  - authorizing against the caller-supplied resource owner: the two RPC
    subtests fail on org1 != org2.
  - dropping the check: they fail on an unexpected ID generation and an
    unfulfilled resource owner lookup, i.e. a code would have been issued.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(management): simulate cross-org passwordless registration link attack

Reproduces the attack against a live instance through the deprecated v1
management API, and pins the two access guarantees the fix has to keep.

Verified by running the three builds against a real instance:

  | build                          | cross-org denied | IAM_USER_MANAGER can send |
  |--------------------------------|------------------|---------------------------|
  | no check                       | FAIL, link with  | PASS                      |
  |                                | plaintext code   |                           |
  |                                | returned         |                           |
  | user.credential.write for both | PASS             | FAIL, AUTH-AWfge          |
  | per-RPC permissions (shipped)  | PASS             | PASS                      |

Row 1 is the vulnerability. Row 2 is why SendPasswordlessRegistration keeps
user.write: IAM_USER_MANAGER holds it instance wide, and instance memberships
resolve in every organization, so the stricter permission would take the
endpoint away from that role everywhere.

ORG_USER_MANAGER gets no separate case: it holds the same user.write through an
org-scoped membership, which the same-org subtest already covers. The auth API's
self-service equivalents have no integration test suite; they are covered by the
self-management unit test instead.

Co-Authored-By: Claude Opus 5 <[email protected]>

* chore: trigger checks

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-14 06:11:39 +02:00
6127b567e7 feat(oidc): provide actor information in userinfo actions (#12566)
# Which Problems Are Solved

Expose impersonating actor to `preAccessToken` and `preUserinfo`
actions.

# How the Problems Are Solved

Provide the `actor` information already present in the OIDC session
model of JWT token to the relevant actions. Both goja-based actions "v1"
and webhook based execution targets carry the actor information now.
Actor remains null in case of non-impersonated tokens. Actor may contain
nested actors to display a delegation chain of impersonators.

# Additional Changes

- dba6261c8ed0dfd00ffde6193910b1405b17432f: refactor `userinfoFlows` to
reduce complexity, add test coverage and solve a couple of potential
bugs.
- `getClientId` in actions was previously undocumented. Added to
documentation.
- A skill that reproduces manual testing using webhook.site or a local
sink.

# Additional Context

- Closes https://github.com/zitadel/zitadel/issues/12097

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Wim Van Laer <[email protected]>
2026-08-12 07:36:56 +00:00
Max PeintnerandGitHub 11912ac749 fix: allow invite codes for users whose auth methods were all removed (#12453)
# Which Problems Are Solved

When a user has no primary auth method (no password, passkey, or IDP
link), the login app routes them to the verify page and lets them
(re)send an invite code. But clicking **Resend** failed with
`Errors.User.AlreadyInitialised` (surfaced in the login UI as the
misleading "User is already verified!").

The cause was in the invite write model (`UserV2InviteWriteModel`): its
`AuthMethodSet` flag was **sticky**. It was set to `true` the first time
a user ever got a password, passkey, or IDP link, and was never cleared
— the removal events weren't even loaded by the query. So a user who
*once* had an auth method that was later removed was permanently treated
as "initialized," even though `ListUserAuthMethodTypes` (what the login
app checks) correctly reported zero methods. That disagreement blocked
invite creation/resend for legitimately method-less users.

# How the Problems Are Solved

Track the user's auth methods as the **current set** instead of a sticky
flag:
- Replaced the `AuthMethodSet bool` field with an `authMethods` set,
keyed uniquely per method (password / IDP link / passkey).
- Reduce now also handles the removal events (`UserIDPLinkRemoved`,
`UserIDPLinkCascadeRemoved`, `HumanPasswordlessTokenRemoved`), and these
event types were added to the write model's query filter so they're
loaded.
- `AuthMethodSet()` is now simply "does the user currently have ≥1 auth
method," so `CreationAllowed()` reflects reality.

The security guard is unchanged: a user who still has any auth method is
refused. The only behavior change is that a user with **no current**
auth method (including one whose methods were all removed) can again
receive an invite code — matching what the login app already assumes. No
extra queries (same single event filter, a few more event types), and no
data migration (pure re-derivation from existing events).

Added tests covering both an initialized user (still refused) and invite
creation succeeding after all auth methods are removed.
2026-08-12 06:24:28 +00:00
318bfc36d7 feat: native app links for passkeys (#12580)
# Which Problems Are Solved

- Native iOS/Android passkeys need OS trust files on the ZITADEL domain;
without them, Associated Domains / App Links verification fails.
- Operators could not configure iOS Team ID + Bundle ID or Android
package name + SHA-256 fingerprints on OIDC apps.
- `/.well-known/apple-app-site-association` and
`/.well-known/assetlinks.json` were not served from application config.

# How the Problems Are Solved

- Add iOS/Android app-link fields on OIDC app create/update (Application
API v2 + Management), with validation.
- Persist and project those fields; query active app-link configs
instance-wide.
- Serve AASA (`webcredentials`) and Digital Asset Links
(`get_login_creds`) from well-known paths, with configurable
`Cache-Control` and fingerprint normalization at serve time.
- Console UI to edit the fields, with links to the well-known endpoints.
- Operator docs for configuration, endpoints, caching, and verification.

# Additional Changes

- Document on API fields that well-known responses may be HTTP-cached
and platform verifiers may delay propagation.
- Runtime config: `WellKnown.AppLinksCacheControlMaxAge` (default `5m`;
`0` → `no-store`).

# Additional Context

- Closes #12497
- Implemented and reviewed as stack:
  - #12531 API contract
  - #12532 storage wiring
  - #12536 well-known endpoints
  - #12537 console
  - #12547 docs

---------

Co-authored-by: Cursor <[email protected]>
2026-08-12 08:04:27 +02:00
2bd42e8fc4 fix(actions): allow adding raw metadata values via appendMetadataRaw (#12567)
# Which Problems Are Solved

Since #10666 (v4.1+, backported to v4.x), metadata values set through
actions v1 (`api.metadata.push` and `api.v1.user.appendMetadata`) are
always JSON-encoded via `json.Marshal`. This made the write path
consistent with the JSON-based read path, but removed the ability to
store raw (unencoded) metadata values:

- A scalar string is now always stored quoted (`"de"` instead of `de`).
- The previous byte-array convention (mapping a string to an integer
array in the script, handled by `mapBytesToByteArray` introduced in
#5526) now stores the literal integer-array text (e.g. `[100,101]`)
instead of the raw bytes.

Customers migrating from v3.x whose downstream systems base64-decode
metadata values from tokens and expect raw bytes have no way to produce
them anymore — changing the consuming system is not always possible.

Reverting the default is not an option either, as clients that adopted
actions v1 on v4.x now rely on the JSON encoding.

# How the Problems Are Solved

- Adds a new, opt-in function `api.v1.user.appendMetadataRaw(key,
value)` to the actions v1 login flows (external / internal
authentication post authentication and pre creation), next to the
existing `appendMetadata`.
- The value is stored as raw bytes without JSON encoding:
  - a string is stored as its plain UTF-8 bytes (`de`, not `"de"`)
- byte arrays (`Uint8Array` or a plain array of integers 0-255, the old
convention) are stored as-is, so existing v3 scripts using a
string-to-byte-array helper only need to switch the function name
  - other types (and empty values) throw an error
- The existing `appendMetadata` and `api.metadata.push` behavior remains
byte-for-byte unchanged.

# Additional Changes

- Documented `appendMetadataRaw` (and the JSON encoding behavior of
`appendMetadata`) in the external and internal authentication actions
docs.
- Added unit tests for the new function (through a real goja runtime)
and a test locking in the existing `appendMetadata` JSON-encoding
behavior.

# Additional Context

- Regression introduced as a side effect of #10666 (which fixed #10470);
the raw byte handling was originally introduced in #5526.
- Reported by a customer upgrading from v3.4.x to v4.16.x, whose
PostAuthentication action maps token payload claims into user metadata.
- Requires backport to v4.x.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-11 15:44:15 +02:00
30434d176c feat(login): Sign in with Zitadel (#12530)
Closes #11824

# Which Problems Are Solved

The backend supports the dedicated ZITADEL identity provider template
(`AddZitadelProvider`, `IDENTITY_PROVIDER_TYPE_ZITADEL`), but the new
login (login v2)
cannot yet handle providers of this type:

A configured and activated ZITADEL IdP is not rendered as a sign-in
option, because the provider type is missing from the login's component
mapping.

# How the Problems Are Solved

- Regenerated `@zitadel/proto` (`settings/v2/login_settings_pb` in
`types`, `es` and `cjs`)
so `IdentityProviderType.ZITADEL = 13` is available (`idp/v2` was
already up to date).
- `apps/login/src/lib/idp.ts`:
- `idpTypeToSlug` maps `ZITADEL` to the `zitadel` slug, used by the
generic
    `/idp/[provider]/process|failure` routes (no route changes needed).
  - `idpTypeToIdentityProviderType` maps `IDPType.IDP_TYPE_ZITADEL` to
    `IdentityProviderType.ZITADEL`.
- Added `SignInWithZitadel` button
(`apps/login/src/components/idps/sign-in-with-zitadel.tsx`)
with the Zitadel logo as inline SVG: the brand gradient is
theme-independent, accents use
`currentColor` to adapt to light/dark, consistent with the other
provider buttons.
- Registered the Zitadel type in the provider-to-component mapping in
`sign-in-with-idp.tsx`.

No changes to the IdP intent handling are required: user
auto-creation/auto-update consume
the pre-mapped `user_action.create_user` / `update_user` from
`RetrieveIdentityProviderIntent`,
which the backend fills for the Zitadel provider like for any generic
OIDC provider.

# Additional Changes

- Added the `signInWithZitadel` i18n key ("Sign in with Zitadel") to all
15 locale files.
- Extended `idp.test.ts` with the new slug and type mappings and added
the Zitadel type to
  the exhaustive enum-coverage tests (45 tests passing).

# Additional Context

- Part of the "Zitadel as an Identity Provider" epic: #5127
- Enables the customer-portal "support access" flow, where support staff
sign in to a
  customer instance via a pre-configured Zitadel Support IdP

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-08-10 10:27:34 +02:00
83854e80e0 fix(telemetry): record route patterns instead of request paths on HTTP metrics (#12557)
# Which Problems Are Solved

- The `uri` label of `http.server.request_count` and
`http.server.return_code_counter` holds the concrete request path, so
every object ID served over a REST path becomes its own metric series.
On our v4.16.2 instance `/debug/metrics` exposes 8,000 series, 4,467 of
which (56%) belong to `http_server_return_code_counter_total` alone,
spread over 4,390 distinct `uri` values such as
`/v2/sessions/385063742120926058`. The set only grows with the number of
objects the installation has served.
- Paths matching no route are recorded verbatim, so unauthenticated
scanner traffic (`/wp-json/`, `/login.php`, …) keeps adding series.
- The grpc-gateway answers `405 Method Not Allowed` without going
through the configured `errorHandler`, so those responses never reported
a route pattern, not even before the regression.

This is a regression, first shipped in v4.11.0; v4.10.1 is unaffected.
#9286 introduced the mechanism, #9523 extended it to unknown paths, and
#11435 removed it while reorganising the middleware packages:

> Removed setting of URI to context in metric middleware. There were
only setters and no getters. (Unused value)

The getter was `*recorder.RequestURI` in `RegisterRequestCounter` /
`RegisterRequestCodeCounter`, which the same PR replaced with
`baseURI(r)`. Both ends went at once, so nothing failed.

# How the Problems Are Solved

- `metrics.WithRequestURIPattern` / `metrics.SetRequestURIPattern` are
back, and `metrics.RequestURI` is now the single place deciding what to
label with: the pattern a router reported, or the requested path if none
did. `UnknownPath` moved to the metrics package so every surface shares
one constant.
- The HTTP metrics middleware prepares the context before passing the
request on, which is the half that got dropped.
- `setRequestURIPattern` in the grpc-gateway reports the pattern to
metrics again, not only to tracing, and is now also called on the `405`
branch.
- Requests routed by chi (the OIDC endpoints) fall back to the pattern
chi matched. That covers the RFC 7592 client configuration routes added
in #12315 (`/oauth/v2/register/{client_id}`), which are templated on the
client ID, and collapses unknown paths below the OIDC prefixes to
`UNKNOWN_PATH` as well.

Against the metrics dump from our instance,
`http_server_return_code_counter_total` drops from 4,467 series to
roughly 35, and the endpoint as a whole from 8,000 to about 3,570.

# Additional Changes

- Regression tests, which were missing and are the reason the revert
went unnoticed:
- `metrics`: resolution of the `uri` label, including that a pattern set
on a derived context reaches the middleware.
- `gateway`: every way the gateway can answer — success, error,
unroutable path, wrong method.
- `middleware`: both routing styles, i.e. a router reporting its own
pattern and a chi routed request.
- A doc comment on `SetRequestURIPattern` explaining that it is written
by routers and read back by the middleware through the context, so it
does not read as an unused setter again.

# Additional Context

- Closes #12556
- Restores #9286 and #9523
- Regressed in #11435

Co-authored-by: Cursor <[email protected]>
2026-08-07 17:23:56 +02:00
be5b278069 test(login): avoid assert.Never race in forged registration check (#12548)
# Which Problems Are Solved

- `TestExternalNotFoundOption_ForgedRegistration_IsRejected` flakes
across unrelated PRs: the test reaches PASS, then panics with `Log in
goroutine after Test... has completed` and `rpc error: code = Canceled
desc = context canceled`.

# How the Problems Are Solved

- Replaces `assert.Never` with a synchronous poll helper
(`requireNeverUserByEmail`) so `ListUsers` and `require` run on the test
goroutine and finish before `TestMain` cancels `CTX`.
- Keeps the same “must stay absent for a window” polarity (not
`Eventually`), so a late-projected forged user still fails the test.

# Additional Changes

- None.

# Additional Context

- CI examples: [run
30980062306](https://github.com/zitadel/zitadel/actions/runs/30980062306),
[run
30837302974](https://github.com/zitadel/zitadel/actions/runs/30837302974)

Co-authored-by: Cursor <[email protected]>
2026-08-06 07:50:21 +02:00
jmaretteandGitHub 9070b537d2 feat(oidc): add RFC 7592 dynamic client management (#12315)
# Which Problems Are Solved

#12313 lets clients register themselves as OIDC applications (RFC 7591),
but a dynamically registered client has no way to manage itself
afterwards:

- The registration response returns no `registration_access_token` and
no `registration_client_uri`, which RFC 7591 and 7592 clients (including
the MCP SDKs) expect in order to manage their own registration.
- There is no way to read back, update or delete a registration without
an operator acting on the client's behalf through the Console or the
Management API. A client that needs to rotate a redirect URI has to ask
a human.

# How the Problems Are Solved

Implement the [OAuth 2.0 Dynamic Client Registration Management Protocol
(RFC 7592)](https://datatracker.ietf.org/doc/html/rfc7592): `GET`, `PUT`
and `DELETE` on `/oauth/v2/register/{client_id}`, wired through the same
`op.WithSetRouter` hook as the registration endpoint.

## Control plane: the same security settings, no new switch

The management endpoints are gated on the
`DynamicClientRegistrationSettings.enabled` introduced in #12313. No new
setting, no feature flag: a client that may register may manage what it
registered. When registration is disabled the endpoints answer `404`,
exactly as `POST /oauth/v2/register` does.

`allow_unauthenticated` selects the registration mode and does not apply
here, because the registration access token authorizes these endpoints
in both modes.

## Authorization: the registration access token, and nothing else

RFC 7592 §3 defines the credential as the registration access token
issued at registration and bound to a single client, not a user
credential. So a client manages only itself, proving it with the token
it was handed:

| | Registration (`POST`) | Management (`GET` / `PUT` / `DELETE`) |
| --- | --- | --- |
| Credential | Access token, or none in open mode | Registration access
token |
| Permission | `project.app.register_dynamic` in token mode | None |

Neither `project.app.register_dynamic`, which governs who may *create*
clients, nor `project.app.write` applies to the management endpoints.
One authorization path per endpoint is easier to reason about than two,
and there is no user identity to check against in the first place.
Operators keep managing dynamically registered clients through the
Management API and the Console, like any other application; they cannot
obtain a client's registration access token, by design.

Status codes: a missing, malformed or non matching token is `401`; a
client that no longer exists is `404`. The token's `client_id` is
checked against the path before any lookup, so a token bound to another
client is rejected without touching the database.

## The token itself

- **Stored like a client secret.** Only its passwap hash is persisted,
through a new
`project.application.config.oidc.registration_token.changed` event; the
plain token leaves the server exactly once, in the response that issues
it.
- **Bound to its client and organization** through authenticated
encryption with the instance key, the same mechanism as refresh tokens.
The opaque token handed to the client encrypts
`client_id:org_id:secret`, so the endpoint recovers the binding before
checking the secret against the stored hash.
- **Verified in O(1)** against the hash on the projection, folded into
the client lookup the handler already does, with a strongly consistent
fallback to the eventstore so a token that was just rotated is accepted
before it is projected.
- **Rotated on update** (RFC 7592 §4.4) and invalidated implicitly when
the client is deleted. A read does not rotate it. Every update rotates,
including one that changes no metadata, so a client always gets a usable
token back.

## Reusing the existing persistence

A small refactor extracts the shared OIDC application creation and
update logic (`pushOIDCApplication` gains a variadic `extraEvents`, and
`oidcApplicationChangeEvent` is lifted out of `UpdateOIDCApplication`),
so the dynamic commands reuse it without the permission check. The
registration token event is pushed atomically with the application, so a
registered client is never left unmanageable.

# Additional Changes

- Setup step **74** adds the `registration_token` column to the
`apps7_oidc_configs` projection, with a reducer for the new event
modelled on the client secret. Applications are not part of the
relational storage projection, so there is no second write path to keep
in sync.
- The registration response (`POST`) now also carries
`registration_access_token` and `registration_client_uri`.
- Docs: a "Manage a registration" section in the dynamic client
registration guide covering the three operations, the authorization
model, token rotation and the new limitations, plus the management
endpoints on the OIDC endpoints page.
- Unit tests for the command layer (issue, verify, rotate, update,
delete), the projection reducer, and the token binding and response
mapping. An integration test covers the full lifecycle (register, read,
update with rotation, delete, read again with `404`), a token bound to
another client, and the endpoints not being served when registration is
disabled. None of the integration calls carries a user access token,
which pins down that the management endpoints require no permission of
their own.

# Additional Context

- Part of the MCP authorization effort in #9810. Follow-up to #12313
(RFC 7591), which is now merged; this branch has been rebased on `main`
and carries the settings and authorization model agreed there.
- **Open question for the review**: the authorization model above
(registration access token only, no operator path through these
endpoints) is the one I proposed when #12313 was reviewed, but it was
never explicitly confirmed. If you would rather have `project.app.write`
also grant access to a client in the operator's own organization, that
is a contained change in `authorizeClientManagement`.
- Rotation is eventually consistent: right after an update the previous
token may keep working for a brief moment until the rotation is
projected, while the new token works immediately through the eventstore
fallback. This matches how ZITADEL validates access tokens, where
revocation is likewise eventually consistent. It is documented in the
guide and asserted as such in the integration test.
- A database migration is introduced for the new projection column. No
proto change and no new feature flag.
- Verified locally: full build, `go vet` including `-tags integration`,
`golangci-lint` with 0 issues, and the unit suites for every touched
package. As before, I cannot run the integration suite here, so CI
remains the check on it.
2026-08-03 08:37:59 +00:00
jmaretteandGitHub f34cc45eac feat(oidc): add RFC 7591 dynamic client registration (#12313)
# Which Problems Are Solved

ZITADEL cannot currently be used as an OAuth 2.0 Authorization Server by
clients that need to register themselves at runtime:

- There is no [OAuth 2.0 Dynamic Client Registration (RFC
7591)](https://datatracker.ietf.org/doc/html/rfc7591) endpoint, and no
`registration_endpoint` is advertised in the discovery document.
- This blocks [Model Context Protocol
(MCP)](https://modelcontextprotocol.io) clients (Claude Desktop,
claude.ai, Cursor, the MCP SDKs), whose OAuth 2.1 authorization profile
expects an Authorization Server that supports dynamic client
registration. They self-register before any user context exists and only
then start the authorization code + PKCE flow.

# How the Problems Are Solved

## Control plane: instance security settings

No feature flag and no runtime config. A
`DynamicClientRegistrationSettings` message is nested under the instance
`SecuritySettings` in `settings/v2`, next to `embedded_iframe` and
`enable_impersonation`:

| `enabled` | `allow_unauthenticated` | Behaviour |
| --- | --- | --- |
| `false` (default) | — | `POST /oauth/v2/register` returns `404`,
`registration_endpoint` absent from discovery |
| `true` | `false` (default) | Token-gated registration, client homed in
the token's organization |
| `true` | `true` | Open registration for MCP hosts, client homed in the
instance default organization |

Both values are carried on `authz.Instance` and loaded with the
instance, exactly like `EnableImpersonation`, so the discovery document
and the endpoint read them per request without an extra query.
`allow_unauthenticated` implies `enabled` in both implementations, so a
single setting closes the endpoint.

Adding the two columns bumps the projection table to
`projections.security_policies3`, following what the impersonation
setting did for `security_policies2`.

## Authorization: a dedicated permission, not `project.app.write`

- New org-scoped permission **`project.app.register_dynamic`**, granted
by default to `ORG_OWNER`, `IAM_OWNER` and `IAM_ORG_MANAGER` — in both
`InternalAuthZ` and `SystemAuthZ`, mirroring where `project.app.write`
already lives.
- New built-in role **`ORG_DYNAMIC_CLIENT_REGISTRAR`** carrying only
that permission, so a service user can self-register clients without
gaining write access to the organization's existing applications.
- Open mode (`allow_unauthenticated = true`) requires no token and no
permission: that is the MCP self-register path.
- A valid token whose user lacks the permission is answered with **`403
insufficient_scope`** (RFC 6750 §3.1) rather than `401 invalid_token`,
so a caller can tell "your token is bad" from "your token may not do
this". `verifyAccessToken` also reports invalid tokens as permission
denied, so the authorization failure is marked explicitly to keep the
two apart.

The check runs once, at the endpoint, right after the token is verified
and before any state is created, so it also gates the auto-provisioning
of the organization's DCR project. The permission itself lives in the
command layer next to the others
(`Commands.CheckPermissionRegisterDynamicClient`). As the OIDC endpoints
are not behind the authorization interceptor, the caller identity is
derived from the verified token, the way token exchange does for the
impersonating actor.

## Registration itself

- **Registered clients are ordinary OIDC applications.** The endpoint
reuses the existing application-creation events
(`NewApplicationAddedEvent` and `NewOIDCConfigAddedEvent`), so the whole
token, authorization and introspection flow keeps working unchanged.
They are stored in a dedicated, auto-provisioned project named `ZITADEL
DCR` per organization (name lookup), so they do not pollute the
`IAMProject` or other projects.
- Input is validated with the existing `GetOIDCV1Compliance` rules;
`private_key_jwt` and `jwks`/`jwks_uri` are rejected with
`invalid_client_metadata`.
- **Endpoint wiring without forking the library.** `NewServer` calls
`op.RegisterServer` instead of `op.RegisterLegacyServer`, so the new
route can be added through the same `op.WithSetRouter` hook as the
authorize callback. `op.RegisterLegacyServer` appends a middleware after
the caller options, which chi forbids once a route has been registered;
`op.NewIssuerInterceptor` reproduces the issuer middleware it would
otherwise add. The `/oauth/v2` routing prefix is unchanged.

# Additional Changes

- **`application_type` inference for custom-scheme redirects** (last
commit, isolated). `application_type` is an OpenID Connect member; RFC
7591 does not define it, so clients that only implement RFC 7591 omit
it. Native MCP hosts doing so register custom-scheme redirect URIs,
which ZITADEL reserves for native applications, and the OIDC default of
`web` rejected them with `invalid_redirect_uri`. Inference is limited to
custom schemes and to an absent `application_type`, so every request
accepted today keeps its current application type and auth method — an
`http` loopback redirect still yields a web application. Happy to drop
this commit if you would rather keep it out of this PR.
- **Docs**: a Dynamic Client Registration integration guide, a
`registration_endpoint` section on the OpenID Connect endpoints page,
and the new role in the administrators table (plus its description in
the Console translations).

# Additional Context

- Part of #9810. This is **Phase 1 (DCR)**; #12315 adds RFC 7592 and
#12316 adds CIMD, both stacked on this branch and rebased once this
lands.
- Implements the control-plane and authorization model requested in the
review of this PR, itself a refinement of the direction in
https://github.com/zitadel/zitadel/issues/9810#issuecomment-4891176997.
- **Out of scope (deliberately)**: RFC 7592, CIMD, persisting free-form
metadata (`logo_uri`, `contacts`, and similar), and `private_key_jwt` /
`jwks_uri` (rejected cleanly). No new event types.
- Follow-ups explicitly left out, per the review: DCR client lifecycle
and richer audit, consent and custom scopes, JWT / `aud` (RFC 8707), and
RFC 8414 AS metadata (currently absent from both ZITADEL and
`zitadel/oidc`).
2026-07-30 14:43:47 +00:00
gayathriandGitHub adb2974662 fix: add the dropped idpTemplate argument (#12511)
# Which Problems Are Solved

This PR fixes a Go compilation error in the Login V1 caused by a
previously dropped `idpTemplate` argument when calling
`registerExternalUser`.

# How the Problems Are Solved

- Pass `idpTemplate` into `registerExternalUser` from
`handleExternalNotFoundOptionCheck` to match the function signature.

# Additional Changes

N/A

# Additional Context

introduced in
https://github.com/zitadel/zitadel/commit/a17af0fe934ff94fcbf6c38d56aee308e1694962#diff-ec5370dd3f47f190c01e8dee4fd027b334a523cf31e5557422dd14c6e6c90339R859
2026-07-29 15:31:34 +02:00
a17af0fe93 Merge commit from fork
* fix(login): prevent external-IDP account pre-hijack in Login V1 (GHSA-738m-7888-jfv8)

The Login V1 endpoint POST /ui/login/externaluser/option
(handleExternalNotFoundOptionCheck) built the new external user's identity and
"verified" flags directly from raw POST fields, without checking the server-side
authReq.LinkingUsers state that a genuine, protocol-verified IDP callback
populates. An unauthenticated attacker could forge that POST to create a real
account bound to an arbitrary (IDPConfigID, ExternalUserID) - enabling account
pre-hijack of a third party whose public external identifier (e.g. a GitHub
numeric user id) was used, so the victim's own future "Sign in with X" logs into
the attacker's pre-created account.

Primary fix:
- Reject the request when authReq.LinkingUsers is empty, mirroring the guarded
  sibling autoCreateExternalUser.
- Source IDPConfigID/ExternalUserID and the email/phone "verified" flags from
  the trusted linkingUser (the real callback data) instead of the form; the
  verified flag only survives when the editable value still matches the IDP's.
  Profile fields (name, nickname, language, edited email/phone) still come from
  the form, which is the page's legitimate purpose.

Secondary hardening:
- Treat the OAuth error/error_description callback params as untrusted so a
  forged GET .../externalidp/callback?error=... can no longer trigger the
  local-auth fallback (RequestLocalAuth) and undermine the IDP-first login
  priority. Genuine code-exchange failures keep their existing handling.

Tests:
- Unit test for mapExternalNotFoundOptionFormDataToLoginUser proving forged
  identity/verified fields are ignored in favor of the trusted callback data.
- A //go:build integration end-to-end reproduction driving the forged
  registration over raw HTTP and asserting the account is not created.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(login): handle IDP error callback for logging, keep local-auth fallback

Addresses review feedback (livio-a) on GHSA-738m-7888-jfv8: the OAuth error
callback is not a security boundary. Blocking only the error= path added no
protection (a garbage code triggers the same fallback), the downgrade only
affects the attacker's own auth request, and a genuine IDP error on the callback
should still fall back to local auth.

- handleExternalLoginCallback: on an IDP error response, log it with context and
  route through externalAuthCallbackFailed so local-auth fallback still happens
  when the login policy allows it (previous behavior), instead of rendering a
  plain login error. The displayed message reuses the existing
  Errors.User.ExternalIDP.LoginFailedSwitchLocal via WrapIdPError.
- Drop the now-unused Errors.ExternalIDP.AuthenticationFailed translation (would
  otherwise need all-language entries) and regenerate the login statik blob.
- Reword the externalIDPCallbackData Error/ErrorDescription comment: logging-only,
  not a security decision.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Livio Spring <[email protected]>
2026-07-29 05:50:40 +02:00
Livio SpringandGitHub fe935d91b8 Merge commit from fork 2026-07-29 05:46:27 +02:00
6fa5c0e662 Merge commit from fork
* feat(login): add shared enrollment authorization guard

Introduce assertSessionAuthorizedForEnrollment, a single gate that decides
whether a session may enroll a new authenticator: it requires a verified
primary factor (password, passkey or IDP) on a non-expired session, or, for
onboarding, a user with no auth methods plus a prior user-verification check.

This mirrors the gate previously inlined only in registerPasskeyLink, which
is refactored to reuse the shared helper. It is the foundation for gating the
remaining credential-enrollment entry points (GHSA-45f2-5q3r-xgg6).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(login): require authentication before WebAuthn/U2F enrollment

addU2F and verifyU2F registered a credential for any session that resolved to
a user id, including a bare identify-only session created by merely submitting
a login name. An unauthenticated attacker who knew a victim's login name could
therefore attach an attacker-controlled authenticator to the victim's account
and then complete a login as the victim.

Both actions now run assertSessionAuthorizedForEnrollment before registering or
verifying, so an unauthenticated session is rejected and no credential is
persisted.

GHSA-45f2-5q3r-xgg6

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(login): require authentication before TOTP/OTP enrollment

verifyTOTP and the otp/<method>/set page activated a TOTP/OTP factor for any
session with a user id, and the u2f/set page rendered the registration form
without an authorization check. An identify-only session could thus enroll a
second factor on a victim's account.

Gate all of them with assertSessionAuthorizedForEnrollment. The setup pages now
render the unknown-context/error state instead of enrolling when the session is
not authorized. Also guard the method value in the OTP set page, since the added
guard branch removes the control-flow narrowing the previous throw provided.

GHSA-45f2-5q3r-xgg6

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(login): require authentication before legacy (V1) passwordless enrollment

The V1 passwordless registration handlers began and verified passwordless setup
for any auth request that carried a user id, without checking that a first
factor had been verified. An unauthenticated attacker could submit a victim's
login name and then POST directly to the passwordless prompt/init endpoints to
plant an attacker-controlled passkey, then log in as the victim.

Add passwordlessSetupAllowed / CheckPasswordlessSetupAllowed, which permit
enrollment only for a legitimate onboarding user (PasswordlessInitRequired) or
an auth request whose first factor is already verified, and call it before
BeginPasswordlessSetup and VerifyPasswordlessSetup. The verify path is also
bound to authReq.UserID instead of the attacker-controllable form field, so a
credential cannot be redirected onto a different account. The init-code
(mail/onboarding) path is unchanged.

GHSA-45f2-5q3r-xgg6

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(login): remove never-active legacy (V1) authReq passwordless setup path

Supersedes the runtime gate added in the previous commit. Maintainer review
(Livio, the original author) and investigation confirmed the auth-request based
passwordless *setup* path in the legacy login was a never-active leftover:

- The passwordless prompt page (passwordless_prompt.html) has no submit button
  and no script; it only tells the user to complete setup via the emailed link
  (the PasswordlessButtonText/NextButtonText/SkipButtonText i18n keys are unused).
- Legitimate onboarding for a PasswordlessInitRequired user goes through the
  emailed init-code flow (authReq == nil, BeginPasswordlessInitCodeSetup /
  VerifyPasswordlessInitCodeSetup), which is untouched.
- The authReq branch (handlePasswordlessPrompt -> BeginPasswordlessSetup /
  VerifyPasswordlessSetup) was reachable only by a crafted direct POST — the
  GHSA-45f2-5q3r-xgg6 attack.

Rather than gate the dead path, remove it entirely, eliminating the attack
surface:
- delete handlePasswordlessPrompt and the POST /login/passwordless/prompt route
  (keep renderPasswordlessPrompt: the informational "use your emailed link" page
  for PasswordlessRegistrationPromptStep),
- make renderPasswordlessRegistration / checkPasswordlessRegistration use only
  the init-code path,
- drop the now-unused AuthRequestRepo.BeginPasswordlessSetup /
  VerifyPasswordlessSetup (and the interface decls) and the passwordlessPromptUrl
  template helper; the underlying HumanAddPasswordlessSetup /
  HumanHumanPasswordlessSetup commands remain (used by the auth-API self-service
  and the v2 passkey APIs).

A crafted POST to /login/passwordless/init carrying an authRequestID but no valid
code now falls through to the init-code path and fails.

GHSA-45f2-5q3r-xgg6

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* check for userverified true as well, cleanup return message

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Max Peintner <[email protected]>
2026-07-29 05:45:56 +02:00
gayathriandGitHub ffd9b39bdb feat: handle Zitadel provider IdP login via login v1 (#12469)
# Which Problems Are Solved

This PR adds support for using a ZITADEL IdP provider in the login v1 UI
flow and conditionally grants a support-user instance membership based
on a ZITADEL-specific project-roles token claim.

# How the Problems Are Solved

- Introduced ZITADEL IdP handling in the login v1 handler, including
extracting the `urn:zitadel:iam:org:project:roles` claim and granting
`IAM_OWNER_VIEWER` instance membership when configured orgs match.
- Added a new `AddInstanceMemberFromLogin` to assign an instance
membership during the login v1 ZITADEL-IdP flow without permission
checks (as the authorization is established based on the project roles
in the token claim)
- Added unit tests

# Additional Changes

N/A

# Additional Context
- Closes https://github.com/zitadel/zitadel/issues/12446
2026-07-28 15:20:46 +02:00
318acd4bc3 perf(query): speed up ListUsers login name equality filters (#12460)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary

`ListUsers` with `LoginNameQuery` + equals/equals-ignore-case was very
slow on large orgs (e.g. ~330k users) because the generated SQL filtered
the `projections.login_names3` **view** on the computed
`login_name_lower` expression and correlated that subquery on
`users14.id`. Postgres nested-looped every user and never used
`login_names3_users_search (instance_id, user_name_lower)`.

This change rewrites the **query planner** for that hot path: when an
equals/equals-ignore-case login-name filter is present (and not under
`OR`/`NOT`), the user list query **INNER JOINs** an indexed matches
subquery instead of filtering via the view expression. The matches SQL
mirrors `user_by_login_name.sql` (`user_name_lower` / domain paths +
`preferred` / `is_primary`).

Non-equals methods and OR combinations keep the previous view-based
filter so semantics stay unchanged.

Also adds a k6 use case that mirrors login v2 discovery
(`loginNameQuery` EQUALS_IGNORE_CASE + `organizationIdQuery`, `limit:
2`):

```bash
cd benchmark
make users_by_login_name USER_AMOUNT=100000 VUS=10 DURATION=60s
```

## Approach

1. `NewLoginNameSearchQuery` for equals / equals-ignore-case returns a
marker `loginNameEqualsFilter` (other methods unchanged).
2. `prepareUsersQuery` extracts that marker when safe, then:
- builds the usual `sq.SelectBuilder` **without** the login-name view
predicate
- adds `JoinClause` to `user_login_name_matches(.sql)` /
`_case_sensitive.sql` as `login_name_matches`
- keeps metadata JOIN/`DISTINCT` only when metadata filters are present
(same as before)
3. Embedded SQL files under `internal/query/` for the matches subquery.

Local smoke against ~330k synthetic users: baseline ~1392ms → rewritten
path ~0.4ms for a single equals-ignore-case lookup.

## Test plan

- [x] `go test ./internal/query/ -run
'TestLoginName|TestUsers|TestUserByLoginName'` (after generate-stubs)
- [x] Existing `user_test` expected SQL updated (no always-on metadata
join; login-name equals uses JOIN)
- [ ] Run k6 before/after on a large `USER_AMOUNT` (e.g. 50k–100k+) and
compare `list_users_duration` p50/p95/p99
- [x] Manual login v2 username discovery against a large org
- [x] Confirm OR / NOT / CONTAINS login-name queries still return
expected results

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-252883bd-48d9-492e-b619-5ccfa93cf9c3"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-252883bd-48d9-492e-b619-5ccfa93cf9c3"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Silvan <[email protected]>
2026-07-28 11:56:07 +00:00
Livio SpringandGitHub d78ed6b5f2 fix: improve random string generation (#12266)
# Which Problems Are Solved

`GenerateRandomString` used for generating codes incl. OTP was
incorrectly ignoring the last rune of the possible set.

# How the Problems Are Solved

Refactored the function to get the full randomness.

# Additional Changes

None

# Additional Context

Thanks @AyushParkara for pointing this out.
2026-07-20 12:57:32 +00:00
b91221143b feat: extend start/retrieve idp intent for Zitadel provider (#12424)
# Which Problems Are Solved

This PR extends `StartIdentityProviderIntent` and
`RetrieveIdentityProviderIntent` for the new Zitadel provider.

# How the Problems Are Solved
- Added `domain.IDPTypeZitadel` cases to both the instance and org
switches in `NewAllIDPWriteModel` to return Zitadel provider when
`GetProvider` is called
- Added Zitadel provider to RetrieveIdentityProviderIntent's provider
type-switch
- Added new `zitadel.go` provider, an OIDC wrapper with forced PKCE
- Added unit/integration tests

# Additional Changes


# Additional Context
- Closes  https://github.com/zitadel/zitadel/issues/12050

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-17 13:24:18 +02:00
Livio SpringandGitHub baf6ed501b Merge commit from fork
* fix(actions): prevent disk access via require

* add test to ensure native modules are still possible
2026-07-17 08:23:31 +02:00
75bc058bce feat(eventstore): autovacuum tuning for events2 table (#12449)
# Which Problems Are Solved

In Zitadel's append-only event-sourced architecture, the
`eventstore.events2` table grows indefinitely. PostgreSQL's default
autovacuum uses a percentage-based scale factor, so as the table grows,
the number of changed rows required to trigger a `VACUUM` or `ANALYZE`
drifts towards infinity. Without regular vacuums, the table's Visibility
Map becomes stale, disabling fast Index-Only Scans and forcing expensive
heap reads. Without regular analyzes, query planner statistics become
stale, leading to suboptimal execution plans.

This causes eventstore operations to progressively degrade as `events2`
grows, even without CPU, memory, or I/O saturation. A manual `VACUUM
ANALYZE` immediately restores performance, confirming the root cause.

- If `Eventstore.Autovacuum` is left at its default, `events2` keeps
using PostgreSQL's default, percentage-based autovacuum/autoanalyze
scale factors, which become impractically infrequent on large tables.
- There was previously no supported way to apply static, table-level
autovacuum tuning to `events2` through Zitadel's own configuration/setup
process.

# How the Problems Are Solved

- Added an `Eventstore.Autovacuum` runtime configuration block to
`cmd/defaults.yaml` (disabled by default):
  ```yaml
  Eventstore:
    Autovacuum:
      Enabled: false # ZITADEL_EVENTSTORE_AUTOVACUUM_ENABLED
VacuumThreshold: 50000 # ZITADEL_EVENTSTORE_AUTOVACUUM_VACUUMTHRESHOLD
AnalyzeThreshold: 50000 # ZITADEL_EVENTSTORE_AUTOVACUUM_ANALYZETHRESHOLD
  ```
- Added a repeatable `zitadel setup` migration step
(`cmd/setup/eventstore_autovacuum.go`) that:
- When `Enabled: true`, disables the percentage-based
`autovacuum_vacuum_scale_factor`, `autovacuum_analyze_scale_factor`, and
`autovacuum_vacuum_insert_scale_factor` on `eventstore.events2`, and
applies static thresholds (`autovacuum_vacuum_insert_threshold`,
`autovacuum_vacuum_threshold`, `autovacuum_analyze_threshold`) from the
config instead.
- When `Enabled: false`, resets those storage parameters on
`eventstore.events2` back to the cluster defaults.
- Implements `Repeatable.Check()` so the step only re-runs when the
configuration actually changed since the last `zitadel setup` run.
  - Added documentation in the new "Performance tuning" page.
 
# Additional Changes

- Move the projection documentation into performance tuning page
- Expand and update the projection documentation to the latest state in
zitadel. (contained some stale information)

# Additional Context

Several other issues describe read-performance symptoms consistent with
this same root cause (stale `events2` visibility map / planner
statistics at scale, without resource saturation). Since this PR
addresses the shared root cause:

- Closes #12448
- Closes #10754
- Closes #10260
- Closes #8585
- Closes #9239


---
_Generated by [Claude
Code](https://claude.ai/code/session_01HoKvEY7niCVgLCz7CajBwW)_

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Silvan <[email protected]>
2026-07-16 14:42:11 +00:00
1e09b4fc19 feat(login): hu translations + I18n consistency v2 (#12447)
# Which Problems Are Solved

Fixes i18n consistency and adds HU translations for login v2

# How the Problems Are Solved

- Updated i18n files and added hu.json
- Moved all translations to v2-default.json

---------

Co-authored-by: Dobos Zoltán <[email protected]>
Co-authored-by: Liam Neville <[email protected]>
2026-07-15 11:27:25 +00:00
6eafca2872 feat: update GetActiveIdentityProviders to return Zitadel in the IdP list (#12422)
# Which Problems Are Solved

This PR extends the Settings Login API (v2 and v2beta) to include
ZITADEL IdP in the list of IdPs returned by `GetActiveIdentityProviders`

# How the Problems Are Solved

- Added `IDENTITY_PROVIDER_TYPE_ZITADEL` to Settings
IdentityProviderType enums (v2 and v2beta) and updated domain display
name handling.
- Updated Settings gRPC converters (v2 and v2beta) to map
`domain.IDPTypeZitadel` to the new proto enum value
- Added unit/integration tests


# Additional Changes
Correctly map `domain.IDPTypeApple` to
`IdentityProviderType_IDENTITY_PROVIDER_TYPE_APPLE` in v2beta instead of
unspecified.

# Additional Context
- Closes https://github.com/zitadel/zitadel/issues/12401

---------

Co-authored-by: Livio Spring <[email protected]>
2026-07-14 13:50:09 +02:00
5bdfd96be5 feat: extend DeleteProvider to include Zitadel IdP (#12396)
# Which Problems Are Solved

This PR extends `DeleteProvider` to include Zitadel provider enabling
the deletion of Zitadel IdP.

# How the Problems Are Solved

- Extend org/instance IDP remove write models to include
`ZitadelIDPAddedEvent` in event appends and queries.
- Extend command-side IDP reduction/type handling for Zitadel IDP add
events.
- Add management/admin integration tests for deleting Zitadel providers
- Add error translation key in all language locales for org-level “IDP
config not existing”.

# Additional Changes

N/A

# Additional Context.
Closes https://github.com/zitadel/zitadel/issues/12397

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Livio Spring <[email protected]>
2026-07-13 09:22:31 +02:00
77169181a3 feat: get/list Zitadel IdP (#12394)
# Which Problems Are Solved

This PR extends the functionality of `GetProviderByID`, `ListProviders`
v1 endpoints and `GetIDPByID` v2 endpoint to also return Zitadel IdP
when queried.

# How the Problems Are Solved

- Add `PROVIDER_TYPE_ZITADEL` and a `ZitadelConfig` to
`zitadel.idp.v1.ProviderConfig` (v1 APIs).
- Add `IDP_TYPE_ZITADEL`, `ZitadelConfig`, `InstanceRolesInfo` in v2
`idp.proto`
- Extend internal/query IDP template querying to include a
`ZitadelIDPTemplate` (incl. issuer, client credentials, scopes, instance
roles info).
- Add/extend integration tests for updating + fetching providers by ID
and listing providers.

# Additional Changes
N/A

# Additional Context
Closes https://github.com/zitadel/zitadel/issues/12051

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-10 09:17:49 +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
9d60e83d6f Merge commit from fork
* Use slices.Contains over custom function

* Correctly remove roles from granted roles

* fix(setup): repair user grants with stale roles (GHSA-v859-c572-qh5p)

Add setup step 73 that reconciles existing user grants whose roles were
left too broad by the buggy cascade removal in removeRoleFromUserGrant.
The corruption lives in the eventstore event payloads, so the step pushes
a corrective user.grant.cascade.changed event per affected grant (roles
intersected with the currently valid set) and re-triggers the user grant
projection. Runs in the second setup slice, after the projection tables
it reads have been created.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(setup): scope GHSA-v859-c572-qh5p repair to grant-based user grants

Direct user grants can never be hit by this bug (only ChangeProjectGrant's
multi-role cascade to grant-based grants can trigger it), so drop the
direct-grant branch from the finder query to avoid stripping unrelated,
legitimate roles that merely mismatch for other reasons (e.g. stale
role_key drift). Also exclude removed instances from the migration scope,
and log the number of grants fixed per instance.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Livio Spring <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-07-09 13:28:09 +02:00
942607e0e9 feat: implement UpdateZitadelProvider in ManagementService (#12384)
# Which Problems Are Solved

This PR adds support for updating ZITADEL identity provider (IDP)
templates.

# How the Problems Are Solved

- Implements `UpdateZitadelProvider` in the Management gRPC server plus
converter
- Introduces an org-scoped `ZitadelIDPChanged` event and registers its
event mapper.
- Adds command-side support to update org Zitadel providers and appends
the new event to the org write model.
- Extends the IDP template projection reducer to accept org change
events.

# Additional Changes
N/A

# Additional Context
Closes https://github.com/zitadel/zitadel/issues/11922
Follow up for PRs:

- https://github.com/zitadel/zitadel/pull/12018
- https://github.com/zitadel/zitadel/pull/12020
- https://github.com/zitadel/zitadel/pull/12055
- https://github.com/zitadel/zitadel/pull/12056
- https://github.com/zitadel/zitadel/pull/12371
- https://github.com/zitadel/zitadel/pull/12378

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Livio Spring <[email protected]>
2026-07-07 04:32:18 +00:00
653a968d91 feat: implement instance-level UpdateZitadelProvider (#12378)
# Which Problems Are Solved

This PR implements the instance-level `UpdateZitadelProvider` endpoint,
enabling updates to existing instance Zitadel IdP templates.

# How the Problems Are Solved

- Added `AdminService.UpdateZitadelProvider` handler, converter
- Added the command-layer implementation to update a ZitadelProvider and
push `instance.idp.zitadel.changed` event
- Added projection-layer implementation to persist Zitadel provider
updates into `projections.idp_templates6_zitadel`
- Added unit and integration tests


# Additional Changes
N/A

# Additional Context
Related to https://github.com/zitadel/zitadel/issues/11922
Follow-up for PRs:
- https://github.com/zitadel/zitadel/pull/12018
- https://github.com/zitadel/zitadel/pull/12020
- https://github.com/zitadel/zitadel/pull/12055
- https://github.com/zitadel/zitadel/pull/12056
- https://github.com/zitadel/zitadel/pull/12371

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-06 10:18:35 +02:00
Livio SpringandGitHub 1e0b810dca feat: allow managing invite code in secret generators (#12109)
# Which Problems Are Solved

Zitadel exposes the secrets generator configuration through its admin
api. This allows instance admins to manage them on their own and they
can create overwrite the system / runtime defaults (incl. expiration).
This very much needed in multi-instance scenarios such as zitadel.cloud.
Currently the invite code configuration was not manageable through the
API, but only runtime config.

# How the Problems Are Solved

- added the `invite_code` type to the API allowing it to be set and
retrieved.
- added the type to console's management list
- added the type to be stored on instance setup
- change the `GetSecretGenerator` endpoint to fall back to the runtime
config if no config is stored on the instance itself
- ensure the `length` and at least one charset is enabled, return an
error otherwise
- expiry is not enforced, so 0 allows codes with no expiry (current
state)

# Additional Changes

None

# Additional Context

- closes https://github.com/zitadel/zitadel/issues/10474
2026-07-01 04:53:16 +00:00
f9995ee39c fix: increase performance of ListUser by login name ignore case (#12350)
# Which Problems Are Solved

Login v2 uses the `users.v2.ListUsers`-endpoint to get a user by login
name. This query had an inefficient `WHERE`-clause.

# How the Problems Are Solved

Update the view and use a specific clause for this query.

# Additional information

Added in https://github.com/zitadel/zitadel/pull/10475

---------

Co-authored-by: Marco A. <[email protected]>
Co-authored-by: Livio Spring <[email protected]>
2026-06-30 11:20:21 +00:00
Wim Van LaerandGitHub 2399513643 Merge commit from fork
* fix: added client and scope validation for token exchange

Verify that the token exchange is not cross client

Only yield scopes which were already on the initial token

* comments

* added test
2026-06-22 11:38:49 +02:00
Livio SpringandGitHub 76fd6d859d Merge commit from fork
* fix: ensure external user's email is verified before auto-linking

* fix linking
2026-06-22 11:38:21 +02:00