mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
5ddee5081e2a9153dc44dd4ed6dae0f7e0d3bfe2
4626
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5ddee5081e
|
fix(console): save button text for Verify OTP Email message text
The Console did not send the button text when saving the Verify OTP Email message text, so any custom value was dropped and the default "Authenticate" was used. Set it in both the organization and the instance default request builders, like for the other email message types. Closes #12799 |
||
|
|
0a08e7269a |
fix(eventstore): omit owners on empty unique constraint inserts (#12785)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> # Which Problems Are Solved After #12740, setup on existing deployments (v4.17.x → v4.18.0) fails before migration 78 can add `eventstore.unique_constraints.owners`. Starting any setup step pushes `system.migration.started`, which inserts a unique constraint that names the `owners` column. On upgraded databases that column does not exist yet, so setup dies on `76_users14_login_equality_indexes` with `SQLSTATE 42703`. # How the Problems Are Solved Empty-owner unique-constraint inserts no longer name the `owners` column: - `len(Owners) == 0` → `INSERT (instance_id, unique_type, unique_field)` - tagged `WithOwners(...)` → unchanged `INSERT (..., owners)` Setup started/done events never stamp owners, so 76–78 can start. Migration 78 still adds the column; after that, omitted `owners` uses `DEFAULT '{}'`. Both v2 and v3 pushers apply the same rule. Mixed pushes split into two INSERT batches so tagged and empty rows do not share a column list. # Additional Changes - v2 SQL mocks now expect the three-column INSERT for untagged adds, plus tagged and mixed cases - Pusher tests cover mixed tagged+empty adds and adds against a table that does not have `owners` yet - The missing-column pusher test drops `owners` and pushes in one transaction (`PushWithClient`), then rolls back so the shared test schema is not mutated. Other tests may stall on `AccessExclusiveLock` until rollback. # Additional Context - Closes #12780 - Follow-up for PR #12740 - Operators do not need to re-run init or apply DDL outside setup Verified locally: - `go test ./internal/eventstore ./internal/v2/eventstore/postgres ./internal/eventstore/v3` (including `-race` on those packages) - Empty-owner insert succeeds when the `owners` column is missing; tagged insert still requires the column <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-31bfb5c4-5888-43f0-85cf-af1af097ca30?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> <a href="https://cursor.com/background-agent?bcId=bc-31bfb5c4-5888-43f0-85cf-af1af097ca30&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> </div> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Silvan <[email protected]> |
||
|
|
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]> |
||
|
|
98272ab5d1 |
docs: fix transparency issue on docs search modal (#12782)
fix transparency issue in light mode for the docs search modal |
||
|
|
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]> |
||
|
|
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> <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> </div>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Silvan <[email protected]>
|
||
|
|
9e1b85fa91 |
fix(login): forward login_hint to the external IdP (#12760)
# Which Problems Are Solved Login V2 never forwards the OIDC `login_hint` to an external identity provider. The user enters their identifier once at ZITADEL, is redirected to the IdP, and has to type it again because the IdP's authorize request contains no `login_hint`. Login V1 has done this since #6899 (`sessionParamsFromAuthRequest`), and the backend half for V2 exists since #11552 (`RedirectURLs.login_hint` on `StartIdentityProviderIntent`), but none of the three places in the login app that start an IdP intent set it. # How the Problems Are Solved Every call to `startIdentityProviderFlow` now sets `urls.loginHint`, mirroring the priority Login V1 uses: - `sendLoginname`: when the user is already known and linked to exactly one IdP, the hint is the username the IdP knows the user by (`IDPLink.userName`), falling back to the typed login name (with the org domain suffix re-attached). When an org's single active IdP triggers the redirect, the hint is the login name. - `handleOIDCFlowInitiation`: when the auth request selects an IdP via `urn:zitadel:iam:org:idp🆔<id>`, the hint is `authRequest.loginHint`. - `redirectToIdp` (the user clicks an IdP button): `SignInWithIdp` gets an optional `loginHint` prop rendered as a hidden form field. The loginname page passes the login name it was prefilled with from `login_hint`; the authenticator setup page passes the session's login name when linking an IdP to an existing user. Unit tests cover all three call sites, including the linked-username priority and the empty-hint case. Closes #12080 Based on #12683 by @aarontraynor |
||
|
|
ceba26f434 |
fix(login): verify API credentials at startup instead of in readiness probe (#12742)
# Which Problems Are Solved The Login V2 readiness endpoint `/ui/v2/login/ready` performs an authenticated `SettingsService.GetGeneralSettings` call on every invocation. This only affects self-hosted Login V2 deployments that probe the endpoint periodically, for example via the Helm chart's readiness and startup probes, the Docker `HEALTHCHECK`, or a custom HTTP probe. In those setups every login replica continuously issues authenticated API calls that pass through the access-log interceptor and are treated as regular authenticated requests of the instance, although they are only probe traffic. The login hosted by ZITADEL Cloud is not affected, and neither are self-hosted deployments that do not probe `/ready`. Independent of the counting, validating credentials on every probe is unusual: readiness probes are meant to report whether a replica can serve traffic, while configuration errors such as invalid credentials are better caught once at startup. # How the Problems Are Solved The readiness endpoint now only checks that the ZITADEL API is reachable and ready, using the API's unauthenticated `/debug/ready` endpoint. That endpoint is served outside the access-log and quota interceptors, so probe traffic is no longer recorded. `CUSTOM_REQUEST_HEADERS` are applied to the readiness request as they are to gRPC requests. The credential check moves to process startup: `register()` in `instrumentation.ts` calls the new `verifyApiCredentials()` once, using the same auth and transport path as real requests. If the API rejects the credentials (`Unauthenticated`/`PermissionDenied`) or no credentials are configured, the process exits with a clear error so misconfiguration is visible immediately. If the API is not reachable at startup, a warning is logged and the process continues; reachability remains the job of the readiness probe and `ZITADEL_API_AWAITINITIALCONN`. Unit tests cover the readiness route, the credential check result mapping, and the startup behavior of `register()`. No changes to the Helm chart or Dockerfile are required. Self-hosted deployments pick up the fix with the next login image; until then, the probe interval can be increased to reduce the number of calls. |
||
|
|
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 |
||
|
|
8ccd5b206f |
docs: migrate to native Fumadocs search & UI fixes (#12732)
Changes: - **Search**: Replaced Inkeep with Fumadocs' native built-in search (createFromSource). Removed @inkeep/cxkit-react and custom search dialogs. - **Hydration Fix**: Removed markdown links from ### headings in egress.mdx (across all versions) to fix the <a inside a> DOM mismatch error. - **Console Fix**: Added the missing key prop to <VersionSelector/> in the layout to resolve the React list warning. --------- Co-authored-by: Florian Forster <[email protected]> Co-authored-by: Claude Fable 5.1 <[email protected]> |
||
|
|
7e34f04361 |
docs: fix broken links to renamed repository paths (#12618)
# Which Problems Are Solved Several links in the docs point at repository paths that no longer exist, so they 404 on GitHub: - The `load-test` directory was renamed to `benchmark`. The benchmarks page still links to `load-test`, `load-test/Makefile` and `load-test/README.md` (5 occurrences). - `apps/docs/content/index.mdx` links to `CONTRIBUTING.mdx`, but the file is `CONTRIBUTING.md`. - The login-app guide links to `apps/login/src/middleware.ts`, which was renamed to `apps/login/src/proxy.ts` in the Next.js 16 upgrade (#11629). # How the Problems Are Solved - Point the benchmarks page at `benchmark/`, `benchmark/Makefile` and `benchmark/README.md`. The `#test` anchor still resolves — `benchmark/README.md` has a `### Test` heading. - Fix the `CONTRIBUTING.md` extension. - Point the middleware example at `apps/login/src/proxy.ts`. Verified by extracting every `github.com/zitadel/zitadel/{blob,tree}/main/...` link from `apps/docs/content` and resolving each one against the working tree — all 14 distinct paths now exist. # Additional Changes - Prose in the benchmarks page that names the directory ("the load-test Makefile", "the load-test readme") now says "benchmark". Generic uses of "load-test"/"load-testing" as an activity are left as they are. # Additional Context - The versioned docs under `apps/docs/content/v4.13`–`v4.16` contain the same stale links, but those directories are generated at build time from release tags by `apps/docs/scripts/fetch-remote-content.mjs` and are gitignored, so they cannot be fixed from `main`. The published pages for those versions will keep the old links. - `apps/docs/scripts/check-links.ts` skips anything starting with `http`, which is why these went unnoticed. Teaching it to resolve `github.com/zitadel/zitadel/blob/main/<path>` links against the working tree would catch this class of breakage in CI — not done here to keep the PR focused. Co-authored-by: Claude Opus 5 <[email protected]> |
||
|
|
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> <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> </div>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Silvan <[email protected]>
|
||
|
|
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> <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> </div> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Silvan <[email protected]> |
||
|
|
95cbe6a796 |
docs: error reference page fails to load, missing basePath in fetch (#12704)
# Which Problems Are Solved The API error reference page (`/docs/apis/errors`) shows "Couldn't load the error reference. Try reloading the page." instead of the table. This started after #12502 switched the page from a static import of `data.json` to fetching it from a route handler at runtime. # How the Problems Are Solved `apps/docs` is served under `basePath: '/docs'` (`next.config.mjs`). The fetch call in `ErrorReference` used `/api/error-reference`, but a plain `fetch()` does not get the basePath prefix added the way `<Link>` and `router.push()` do, so the request always 404'd. Fetches `/docs/api/error-reference` instead, the actual prefixed route. # Additional Changes None. # Additional Context - Follow-up for #12502 - Confirmed locally: `curl localhost:3000/api/error-reference` returns 404, `curl localhost:3000/docs/api/error-reference` returns 200 with the full catalog Co-authored-by: Rajat Singh <[email protected]> |
||
|
|
e8d95f306b |
docs: add searchable API error reference (#12502)
## Summary - Add a searchable catalog of every error ID ZITADEL's gRPC/Connect APIs can return (`content/apis/errors.mdx`), wired into the sidebar next to "gRPC Status Codes" (with a cross-link added there too). There was previously no such reference — error IDs are assigned ad hoc at ~3,800 call sites across the backend. - `scripts/generate-error-reference.ts` scans `internal/**/*.go` and `backend/**/*.go` for `zerrors.Throw*()` call sites, resolves i18n messages, and clusters ~3,200 distinct IDs into ~1,160 distinct causes by (subsystem, gRPC code, message) — so identical causes are merged into one entry instead of repeated ~3,200 times. - Detects real ID collisions (same ID thrown for genuinely different reasons at different call sites) and surfaces them as warnings, rather than silently merging them. - Generates a worked example API response for every cause. - 116 of the highest-impact causes (covering the majority of call sites) carry an explanation written against the actual source rather than the raw message text; the rest carry a mechanically derived explanation — both are visibly labeled by source so the depth of research is transparent. - New `ErrorReference` component renders the catalog as a searchable, collapsible subsystem → gRPC code → cause tree. The generator is a standalone script (`pnpm generate:error-reference` in `apps/docs`), not wired into the docs build's `generate` chain, since scanning the whole Go backend on every docs build would be wasteful. The generated `data.json` and the curated overrides are committed, the same way `content/apis/benchmarks/**/output.json` is already committed despite being externally generated. ## Test plan - [x] `pnpm nx run @zitadel/docs:check-types` passes - [x] `pnpm nx run @zitadel/docs:lint` passes - [x] `pnpm nx run @zitadel/docs:check-links` passes - [x] Verified locally in `pnpm nx run @zitadel/docs:dev`: page renders, tree expands/collapses, search filters by ID/message, collision warnings render correctly (e.g. `AUTHZ-Kl3p0`) --------- Co-authored-by: Rajat Singh <[email protected]> Co-authored-by: Claude Sonnet 5 <[email protected]> Co-authored-by: Marco A. <[email protected]> |
||
|
|
8b87ca50d4 |
feat(telemetry): auto detect google attributes (#12694)
# Which Problems Are Solved We run multiple deployments (regions) under a single GCP project. Within these deployments we scale containers. Currently all metrics target the same time series in GCP, regardless or region or container. All metics ended up in `generic_node` and Cloud Monitoring rejects the duplicate and out-of-order points. This can be fixed by setting vendor specific attributes such as `faas.name` (K_SERVICE), `faas.version` (K_REVISION), `faas.instance` (instance id from the metadata server), and `cloud.region` (metadata instance/region). # How the Problems Are Solved Use `go.opentelemetry.io/contrib/detectors/gcp` to auto-detect values as mentioned above. A new `Instrumentation.Detectors` config entry is added, which accepts and array of enum for detecotrs. At the moment only Google is accepted, other entries from [contrib](https://github.com/open-telemetry/opentelemetry-go-contrib/tree/main/detectors) can be added if deemed usefull. I've considered using `autodetect` contrib package, identifiers and constructor, but this pulls in the full AWS SDK, increasing zitadel's binary size by 50MiB. By default detection is switched off, as it relies on unauthenticated link-local calls that are insecure anywhere outside of the infrastructure it targets. It also prevents breaking existing deployments that rely on detection being off already. (May break existing trace and logging pipelines). # Additional Changes - none # Additional Context - (internal only): https://github.com/caos/infra/pull/1813 --------- Co-authored-by: Copilot Autofix powered by AI <[email protected]> Co-authored-by: Claude Opus 5 <[email protected]> |
||
|
|
13948f2bcd |
docs: update knowledge gap ID 66 (#12691)
Automatically generated PR resolving Knowledge Gap ID 66. **Thread ID:** manual-1788445047146 **Action:** UPDATE **Target Files:** 1 ### AI Summary > We need to add to the support page a section for a new feature that allows support staff to access a customer's instance to review logs, configurations and troubleshoot issues. ### Human Reviewer Instructions _No extra context provided._ --- 🤖 **Need adjustments?** Leave a comment below and tag **@zitadel-knowledge-bot** with your requested changes, and I will automatically update the files and push a new commit! --------- Co-authored-by: zitadel-knowledge-bot[bot] <296006658+zitadel-knowledge-bot[bot]@users.noreply.github.com> Co-authored-by: fcoppede <[email protected]> |
||
|
|
8dac4e2b54 |
docs: add instance administrator hardening guide (#12678)
# Which Problems Are Solved - Operators lack a single guide for separating instance-level principals (break-glass, login client) from tenant organizations. - Login policy and IdP behavior for platform accounts is easy to conflate with customer org settings when admins share one organization. - There is no documented procedure to inventory instance administrators or detect unexpected grants over time. # How the Problems Are Solved - Add Hardening instance administrators covering: - Dedicated platform organization layout and why login, password, and lockout policies matter for operator accounts. - Login client (`IAM_LOGIN_CLIENT`) and break-glass (`IAM_OWNER`) hygiene. - Periodic review with Internal Permission Service v2 `ListAdministrators` (instance and platform org filters). - Actions v2 webhooks for `instance.member.*` and `org.member.*` events (with payload interpretation and alert hints). - Event API polling alternative for SIEM/batch pipelines (`instance.member.*` and platform-org `org.member.*`). - Register the page in the docs sidebar under Roles & Permissions. - Cross-link from administrators, production checklists, login-client setup, and service account authentication guides. # Additional Changes - Clarify instance vs org IdP availability depends on effective login policy and linked providers (not automatic for all orgs). - Note deprecated Admin API `POST /admin/v1/members/_search` vs v2 `ListAdministrators`. # Additional Context - Operational hardening guide only (no product authz changes). --------- Co-authored-by: Cursor <[email protected]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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. |
||
|
|
ee133d0fa4 |
chore: gRPC-Go deps update to 1.83.2 (#12680)
# Which Problems Are Solved Dependency update of google.golang.org/grpc to 1.83.2 # How the Problems Are Solved `go get -u google.golang.org/grpc` |
||
|
|
3be21ccbf0 |
docs(perf): v4.17.1 results and harness fixes (#12651)
Publishes the v4.17.1 benchmark sweep and the harness fixes the sweep uncovered. ## Results All 11 published targets ran for 30 minutes each against a fixed 7-container deployment (vCPU 6 / 6 Gi, psql v17.4, US1 client and server), at 600 VUs except where noted. Latencies are `http_req_duration` over the test phase. | target | VUs | iter/s | p50 | p95 | p99 | failed requests | |:---|---:|---:|---:|---:|---:|:---| | machine_pat_login | 600 | 3452 | 24.53ms | 872ms | 1.00s | 29 / 6,335,633 (0.00%) | | user_info | 600 | 3217 | 29.32ms | 943ms | 1.13s | 66 / 5,800,788 (0.00%) | | introspect | 600 | 2297 | 46.49ms | 1.16s | 1.36s | 1,400 / 4,159,863 (0.03%) | | machine_jwt_profile_grant | 600 | 1076 | 85.34ms | 2.57s | 2.93s | 97 / 1,986,156 (0.00%) | | machine_client_credentials_login | 600 | 834 | 56.78ms | 1.74s | 2.42s | 1,789 / 3,073,497 (0.05%) | | add_session | 600 | 767 | 95.25ms | 3.53s | 4.15s | 1 / 1,416,546 (0.00%) | | oidc_session | 600 | 326 | 77.47ms | 3.41s | 4.43s | 3 / 1,767,162 (0.00%) | | otp_session | 600 | 84 | 47.80ms | 13.83s | 20.22s | 182 / 466,756 (0.03%) | | password_session | 600 | 68 | 408.63ms | 20.00s | 24.05s | 1 / 253,280 (0.00%) | | human_password_login | 600 | 49 | 146.67ms | 4.08s | 1m0s | 9,227 / 683,256 (1.35%) | | manipulate_user | **200** | 19 | 304.58ms | 10.23s | 38.43s | 225 / 171,987 (0.13%) | Against the goals: - **#8352 is met.** `machine_jwt_profile_grant` reached **1076/s** against the 1000/s goal, with p50 at 85ms. - **#4424 is not met.** `human_password_login` runs at **49/s** against a 1200/s goal. The sweep ran with `improvedPerformance` enabled (`PROJECT_GRANT`, `PROJECT`, `USER_GRANT`, `ORG_DOMAIN_VERIFIED`), captured from the instance before the first target rather than assumed; the full flag set is on every page. ## What the observability rows show The containers were never the constraint. ZITADEL CPU sat at 9–50% (p50), memory at 6–7% (p50, peaking at 16%) of 6 GiB, and instance count was flat at 7 with no container startups in any window. Database CPU was at **86–98% median on nine of eleven targets**. Any "ZITADEL achieves N/s" figure that omits which resource ran out is true and misleading, so the pages say. The query breakdown then shows what the CPU numbers cannot: **advisory lock wait is the single largest category of database time across the sweep (91,857 s accumulated)**, ahead of event reads (64,805 s), event push (26,411 s) and projection reads (14,625 s). The three locks are the projection handler lock (`handler/v2/handler.go`), the eventstore read barrier (`repository/sql/query.go`) and the shared lock every push takes (`eventstore/v3/push.go`). That is what sets the `flowchart outcome` on each page, and it is not uniform: - **Scale** on the seven targets where a resource genuinely ran out — database CPU at 92–98% with 0% lock share. - **Potential issues** on `human_password_login`, `password_session`, `otp_session` and `manipulate_user`, where 45–74% of the top query time is lock wait. `password_session` runs at 51% database CPU and 50% container CPU and still only manages 68 iter/s; adding vCPU there buys nothing. Autovacuum is cleared as a contributor: 93.9% median DB CPU in buckets containing a vacuum against 93.1% without, within noise. The new v4.17 eventstore autovacuum is barely triggered — `events2` is not in the top twenty vacuumed tables. ## Harness fixes Defects found during the sweep, all of which corrupted published numbers or aborted runs: 1. **Colliding check names.** `updateHuman`, `lockUser` and `deleteUser` all registered checks as `update user is status ok`, two of them asserting `status === 201` against endpoints returning 200. k6 aggregates checks by name, so this produced a permanent, fictional 28% failure rate. Each check now has its own name and accepts 2xx. 2. **User-creation read-after-write race**, in three variants, each found after the previous fix. The read-back resolved `undefined` when the projection was behind, surfacing much later as `Cannot read property '0' of undefined`; then an unguarded `res.json()` turned every request timeout into a killed iteration; then a *partial* read — a valid user object with `loginNames` not yet projected — passed an `if (user)` guard and failed identically. `readUserAfterCreate()` now guards status and body before parsing, requires the field rather than the object, and retries with backoff, rejecting with the user id and username. 3. **Metric cardinality.** Id-bearing URLs produced 800k+ time series against k6's suggested 100k, driving k6 to 6.8 GB and an OOM mid-run. All 16 such call sites now set a grouped `name` tag. 4. **`password_session` wrote its CSV to `otp_session`'s filename** in the Makefile, silently overwriting the previous target's output. The read-back race is not only a harness defect: at 600 VUs it measures real load-induced projection lag, exceeding 25 s for login-name projection and 100 s for the user projection, against instant reads on an unloaded instance. ## Stable v2 user API Four calls in `benchmark/src/user.ts` still went to `/v2beta/users` — update, lock, delete and the machine read-back. They date from the original load-test bootstrap in April 2024 (#7635), when the user service was still v2beta, and were never moved when it was promoted; they surfaced in this diff only because the read-after-write fix rewrote the surrounding functions. All four are now on stable v2. `updateHuman` is the one that changed shape: `PUT /v2beta/users/{id}` is `UpdateHumanUser`, whose stable-v2 counterpart `PUT /v2/users/human/{id}` is deprecated in the proto, so it now calls `PATCH /v2/users/{id}` (`UpdateUser`) and `manipulate_user` nests the profile under `human`. Both RPCs land on `command.ChangeUserHuman`, so the measured work is unchanged. `lockUser`, `deleteUser` and the `createMachine` read-back move over unchanged in method and payload. No published result is affected: per-endpoint latencies come from custom Trends, and `run-bench.sh` drops the `name` system tag, so no artefact ever recorded a v2beta URL. The remaining `/management/v1` and `/admin/v1` calls are left alone — migrating those changes which API surface is benchmarked and would need a re-run. ## Timestamps Pages reported `Test start` as a bare `HH:MM`, so a run could not be correlated against monitoring the next day. Pages now carry the full UTC date and time and a `Test end` row, both derived from the first and last per-second row of `output.json`. `_template.mdx` is updated to match. ## Post-mortem `benchmark/postmortems/2026-08-27-503-burst.md` records two bursts of HTTP 503 `Service error -27` — an HTML page from the Google frontend, not a ZITADEL error — on `manipulate_user` and `human_password_login`, six hours apart. **The investigation is open**, and the note is explicit about what is established (no container startups; the two bursts have opposite server-side signatures), what is provisionally ruled out (restart, panic), and what is not yet known. It is included because both affected pages report the 503s as a separate population from the ZITADEL failures beside them, and that split needs its evidence somewhere. It also records two defects found by accident in the collected logs, unrelated to the 503s: `sql: transaction has already been committed or rolled back` (232 occurrences under load) and a missing i18n key, `"unable to get user by id" not found in language "en"`. ## Notes for review - **`manipulate_user` ran at 200 VUs, not 600, and the page says so.** Throughput saturates near 100 VUs; at 600 the same target produced 4,283 failed requests for identical throughput against 225 at 200 VUs. Its failure mode is degradation over sustained single-org churn — per-operation p50 climbs 1.7x–6x between the first and last five minutes of a run — not a concurrency threshold. - **Two `Observed errors` rows are hand-corrected** and deliberately differ from the generated text, on `manipulate_user` and `human_password_login`. Both cases are one generated number covering two unrelated failure populations; each page says it was corrected and why. - Org deletion failing with a 500 (`V3-C8l3V`) after heavy user churn is already tracked as #8510 and was hit repeatedly during cleanup. - **The run/publish tooling is no longer part of this PR.** It moved to a private repository, along with the cloud collection scripts, because deployment identity does not belong here. What remains is the published pages, the post-mortem findings and the harness fixes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
8de625dc48 |
docs(oidc): remove stale back-channel logout unimplemented notice (#12660)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> # Which Problems Are Solved - The OIDC logout guide still claimed Back-Channel Logout was not implemented in ZITADEL, even though the feature is supported and documented elsewhere. # How the Problems Are Solved - Removed the outdated "This is not yet implemented in ZITADEL" callout from the Back-Channel Logout section of the logout guide. - Added a short note linking to the dedicated [OIDC Back-Channel Logout](/guides/integrate/back-channel-logout) guide. - Left the Front-Channel Logout "not yet implemented" callout unchanged, since that mechanism is still unsupported. # Additional Changes - None. # Additional Context - Highlighted in https://github.com/zitadel/zitadel/issues/8287#issuecomment-5425724614 - Related epic: #8287 <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-3be6fda6-ce62-409c-96f0-f7ae9c58914d?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> <a href="https://cursor.com/background-agent?bcId=bc-3be6fda6-ce62-409c-96f0-f7ae9c58914d&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> </div> Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Silvan <[email protected]> |
||
|
|
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]> |
||
|
|
94f53145da |
chore(ci): let nx stop continuous run-db tasks instead of compose down from within the test targets (#12663)
# Which Problems Are Solved CI runs have been flaky since ~Aug 27 with the nx step failing on ``` Task "@zitadel/api:test-integration-run-db" is continuous but exited with code 0 Task "@zitadel/functional-ui:run-db" is continuous but exited with code 0 ``` even when every test passed, and with the sibling test suite killed mid-run (`Process completed with exit code 130`). Root cause: the test targets shoot their own database. `@zitadel/api:test-integration` and `@zitadel/functional-ui:test` end their command list with an explicit `nx run ...:stop` (`compose down`) of the very service their **continuous** `run-db` dependency (`compose up`) is supervising. The `run-db` task then exits on its own, which nx (22.x) counts as a **failed task**: 1. the nx run is marked failed even with all tests green, and 2. with `--nxBail`, the other suite that is still running is aborted mid-flight. Whichever suite finishes first triggers it, so the failure is timing-dependent and flaky. Recent examples: [attempt 1 of this run](https://github.com/zitadel/zitadel/actions/runs/33161619698/attempts/1) (all 42 integration packages + all Cypress specs green, job still failed) and [this run](https://github.com/zitadel/zitadel/actions/runs/33166856821) (Cypress finished first, integration tests killed at 4m13s). # How the Problems Are Solved Drop the trailing `nx run ...:stop` commands from the two test targets. nx stops continuous dependencies itself once all their dependents finish, so the self-inflicted exit never happens. The `stop` targets remain unchanged for manual use as documented in CONTRIBUTING.md. Since this PR touches both projects' configuration, both test suites run on this PR — a green pipeline here exercises exactly the raced path. # Additional Context - The race existed since the nx 22 upgrade (#12592) but only started losing consistently around Aug 27, presumably due to changed teardown timing on the updated Depot runner images (same window as the `*.integration.localhost` DNS breakage worked around in #12621). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <[email protected]> |
||
|
|
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]> |
||
|
|
27ce10276b |
docs: create knowledge gap ID 65 (#12646)
Automatically generated PR resolving Knowledge Gap ID 65. **Thread ID:** manual-1787771291264 **Action:** CREATE **New File:** `apps/docs/content/guides/integrate/login-ui/fork-and-deploy-login-app.mdx` ### AI Summary > My recommendation is to publish a a new Deploy & Operate guide for the Login App, then link to it from the existing Login App page. The existing pages address architecture/development, login-client authorization, and Login V2 adoption separately, but none currently explains long-term fork ownership and upgrades. (Contribution guide, login-client setup, adoption guide) The article should cover: Full-repository fork as the simplest current recommendation. The customer’s history-preserving slim-fork approach as an advanced alternative. Why copying only apps/login is unsafe while the current npm packages remain unavailable, linked to #12250. Release-tag pinning and versioning such as v4.17.0-custom.1. Development, generation, linting, tests, and Docker builds. An EKS/Kubernetes Deployment and Service example. PAT handling through ZITADEL_SERVICE_USER_TOKEN_FILE. Same-origin versus separate-domain proxy requirements. Gradual rollout, break-glass access, rollback, and troubleshooting. A repeatable upstream synchronization and release process. ### Human Reviewer Instructions > Sample content: > Maintain and deploy a customized ZITADEL Login UI > > The ZITADEL Login UI is an MIT-licensed Next.js application maintained in the ZITADEL monorepo. You can customize and deploy it independently, but your fork becomes an application that your team must build, test, secure, monitor, and update. > > This guide describes the current workflow for maintaining a customized Login UI. It is intended for teams that need source-code changes beyond the branding and text customization available through ZITADEL settings. > > If you only need to change colors, logos, fonts, or translated login text, first use ZITADEL's supported branding and customization settings. A source fork creates an ongoing maintenance responsibility. > > Understand the source dependencies > > The Login UI is not currently an isolated project in the repository. Its relevant dependency chain is: > > apps/login > └── packages/zitadel-client > └── packages/zitadel-proto > └── proto > > The root pnpm workspace and Nx configuration also participate in installing, generating, testing, and building these projects. Copying only apps/login can therefore fail or silently separate the UI from the API definitions against which it was developed. > > For the current monorepo workflow, keep the Login UI and these workspace dependencies on the same upstream ZITADEL revision. > > Choose a fork strategy > > Full repository fork — recommended > > Fork the complete zitadel/zitadel repository and limit your own changes to apps/login wherever possible. This is the simplest model to understand and the least likely to miss a new build-time dependency when upstream changes. > > Use two remotes: > > git clone [email protected]:YOUR_ORGANIZATION/zitadel.git > cd zitadel > git remote add upstream https://github.com/zitadel/zitadel.git > git fetch upstream --tags > > Start customization from a released tag rather than an arbitrary commit on main: > > git checkout -b custom-login/vX.Y.Z vX.Y.Z > > Replace vX.Y.Z with the ZITADEL release you have selected. A release tag gives you a reproducible compatibility baseline and a corresponding changelog. > > History-preserving slim fork — advanced > > If repository size or internal policy makes a full fork impractical, you can maintain a filtered fork containing only: > > apps/login > > packages/zitadel-client > > packages/zitadel-proto > > proto > > the root pnpm, lockfile, Node, and Nx configuration required by those projects > > Use git filter-repo identically both when bootstrapping the fork and during every later synchronization. The list of retained paths and all filter arguments must remain stable so that freshly filtered upstream history shares commit ancestry with your fork. > > Important constraints for this model: > > Bootstrap and update with one shared filtering implementation; do not duplicate the path list in separate scripts. > > Merge the initial import and subsequent update branches with a merge commit or fast-forward. Do not squash them, because squashing removes the filtered upstream ancestry needed for future three-way merges. > > Treat any change to the retained path list as a new bootstrap. Adding or removing paths rewrites the filtered commit IDs. > > Re-run pnpm install after filtering or merging so the lockfile is reconciled with the retained workspace. > > Review the retained path list whenever upstream changes the Login build graph. > > This is a valid Git maintenance technique, but it adds machinery that ZITADEL's normal build does not require. Own and test the filtering automation as part of your fork. > > Why a standalone apps/login copy is not recommended today > > At the time of writing, current @zitadel/client and @zitadel/proto releases are not available as version-matched npm dependencies for the Login UI. The application consumes their workspace sources from the monorepo. Until version-matched packages are published, retain those sources rather than replacing the workspace dependencies with old npm versions. Progress is tracked in zitadel/zitadel#12250. > > Configure a development environment > > The current contribution guide requires Node.js 22.x, Corepack/pnpm, and Nx. From the repository root: > > corepack enable > pnpm install > pnpm nx run-many --target generate > > To develop the Login UI against an existing ZITADEL instance, create apps/login/.env.dev.local: > > ZITADEL_API_URL=https://your-zitadel-instance.example.com > ZITADEL_SERVICE_USER_TOKEN=your-login-client-pat > > Do not commit this file or its token. ZITADEL_API_URL must not have a trailing slash. > > Start the development server: > > pnpm nx run @zitadel/login:dev > > The token must belong to a machine user with the Instance Login Client role (IAM_LOGIN_CLIENT). See Connect your self-hosted Login UI to ZITADEL for the supported setup. > > Run the required quality checks > > Run the checks for all three affected projects, not only the Next.js application: > > pnpm nx run-many \ > --projects @zitadel/login @zitadel/client @zitadel/proto \ > --targets lint build test > > If your update includes .proto changes, regenerate the TypeScript clients before testing: > > pnpm nx run @zitadel/proto:generate > > For meaningful Login UI changes, also perform end-to-end tests against a staging ZITADEL instance. At minimum, cover the authentication methods and policies you enable in production: > > Authorization Code Flow with PKCE and callback to the application > > password, passkey, external identity provider, and applicable MFA/OTP flows > > registration, account selection, password reset, verification, and logout > > organization-specific policies, branding, and translated text > > expected negative cases such as invalid or expired codes > > desktop and mobile browsers supported by your application > > Build an immutable image > > The repository's documented Docker build flow is: > > pnpm nx run @zitadel/login:build > docker build -t registry.example.com/identity/zitadel-login:vX.Y.Z-custom.1 apps/login > docker push registry.example.com/identity/zitadel-login:vX.Y.Z-custom.1 > > Use an immutable tag that records both the upstream ZITADEL baseline and your customization revision. For example: > > v4.17.0-custom.1 > > Also retain the source commit and image digest in your release record. Do not deploy a mutable latest tag to production. > > Deploy outside Vercel > > Vercel is not required. The Login UI can run as the Node.js application produced by the Next.js build or as a container. In Kubernetes or EKS, run it as a separate Deployment and Service behind your ingress or load balancer. > > The following fragment shows the important application settings. Adapt registry authentication, resources, topology, and ingress to your platform: > > apiVersion: apps/v1 > kind: Deployment > metadata: > name: zitadel-login > spec: > replicas: 2 > selector: > matchLabels: > app: zitadel-login > template: > metadata: > labels: > app: zitadel-login > spec: > containers: > - name: login > image: registry.example.com/identity/zitadel-login:vX.Y.Z-custom.1 > ports: > - name: http > containerPort: 3000 > env: > - name: ZITADEL_API_URL > value: https://zitadel.example.com > - name: NEXT_PUBLIC_BASE_PATH > value: /ui/v2/login > - name: ZITADEL_SERVICE_USER_TOKEN_FILE > value: /var/run/secrets/zitadel/login-client.pat > volumeMounts: > - name: login-client > mountPath: /var/run/secrets/zitadel > readOnly: true > readinessProbe: > tcpSocket: > port: http > livenessProbe: > tcpSocket: > port: http > volumes: > - name: login-client > secret: > secretName: zitadel-login-client > --- > apiVersion: v1 > kind: Service > metadata: > name: zitadel-login > spec: > selector: > app: zitadel-login > ports: > - name: http > port: 3000 > targetPort: http > > Store the PAT in a Kubernetes Secret or external secrets manager and expose it as a read-only file. Restrict access to the Secret and plan token rotation. Do not bake the PAT into the image. > > Add CPU and memory requests and limits based on load tests from your own authentication traffic. Use multiple replicas, disruption controls, and multi-zone scheduling according to your availability requirements. > > Configure routing and domains > > The Login UI contains a server-side component and Next.js middleware that proxies OIDC-related requests to ZITADEL. Correct proxy routing is part of the authentication flow, not only static frontend delivery. > > Same public origin > > When the Login UI and ZITADEL share a public origin, route: > > /ui/v2/login to the Login UI service > > all other ZITADEL paths to the ZITADEL API service > > Set NEXT_PUBLIC_BASE_PATH=/ui/v2/login at build and runtime as required by your packaging. Keep the value consistent with the Login V2 base URI configured in ZITADEL. > > Separate Login domain > > If the Login UI uses a separate public domain such as login.example.com: > > Serve it over HTTPS. > > Add the Login domain to the instance's Trusted Domains. > > Configure the proxy in front of the Login UI as documented by ZITADEL: > > x-zitadel-public-host: the public Login UI host, for example login.example.com > > x-zitadel-instance-host: the ZITADEL instance host, for example auth.example.com > > Test OIDC and SAML callbacks and every configured external identity provider through the public route. > > Do not assume that setting only ZITADEL_API_URL replaces the required host-routing behavior for every topology. The Login App architectural and proxy guidance is the source of truth for these headers. > > Enable Login V2 safely > > For an existing self-hosted ZITADEL installation, follow Adopt Login V2 on an existing installation. > > Prefer a gradual rollout: > > Deploy the custom Login UI without changing the instance-wide setting. > > Enable the new Login UI for a non-production test application and set its custom base URL. > > Run the complete authentication test matrix. > > Expand to selected applications. > > Enable it instance-wide only after validation, if that is your intended topology. > > Keep a break-glass PAT for a machine user with the Instance Owner role (IAM_OWNER) so that you can revert the Login V2 setting if interactive login becomes unavailable. Store and audit this credential according to your incident-access policy. > > Keep the fork synchronized > > Treat upstream synchronization as a normal application dependency update, not an automatic deployment. > > For a full repository fork: > > git fetch upstream --tags > git checkout -b update-login-vX.Y.Z > git merge vX.Y.Z > > Resolve conflicts while preserving the intent of your customizations. Then install, generate, test, and build again. For a slim fork, fetch a freshly filtered copy of the same release and merge its history using the identical filter configuration used during bootstrap. > > Recommended update workflow: > > Select a released ZITADEL version and read its release notes and migration guidance. > > Merge that release into a dedicated update branch. > > Review changes to apps/login, packages/zitadel-client, packages/zitadel-proto, proto, and the root build files. > > Reconcile dependencies and generated code. > > Run lint, build, unit, and staging end-to-end tests. > > Build an immutable custom image and scan it using your normal supply-chain controls. > > Deploy to staging, then canary or gradually roll out to production. > > Retain the previous image digest and configuration for rollback. > > Do not merge upstream continuously into production without qualification. Login changes can affect cookies, redirects, external IdP callbacks, API contracts, and authentication-policy behavior. > > Recommended release policy for your fork > > ZITADEL releases are the upstream input; your custom Login UI image is your own release artifact. A useful version format is: > > <upstream-zitadel-version>-custom.<revision> > > Maintain a short release record containing: > > upstream tag and commit > > custom fork commit > > container image digest > > applicable ZITADEL backend version or tested range > > configuration changes and migrations > > test evidence and known limitations > > Subscribe to ZITADEL releases and security notices. Decide deliberately which releases to adopt; prioritize security fixes and changes that affect Login V2 or the APIs it consumes. > > Troubleshooting checklist > > If the Login UI returns an internal error or an authentication flow redirects incorrectly, verify: > > the PAT is readable and belongs to a user with IAM_LOGIN_CLIENT > > ZITADEL_API_URL is reachable from the Login pod and has no trailing slash > > the public Login domain is trusted by the ZITADEL instance > > the configured Login V2 base URI exactly matches the public route and base path > > the ingress sends Login UI traffic to the correct service > > separate-domain deployments set the public-host and instance-host headers correctly > > the selected Login source, client package, proto package, and ZITADEL API version were tested together > > external IdP callback URLs reach the expected public endpoint > > the prior known-good image and configuration can still be restored > > Related documentation > > Login App architecture and setup > > Contribute to the Login UI > > Connect your self-hosted Login UI to ZITADEL > > Adopt Login V2 on an existing installation > > ZITADEL releases ### ⚠️ Manual Action Required > **Sidebar Update Needed:** Because this PR creates a brand new document, it will not automatically appear in the site's navigation. Please review the file location and manually update `apps/docs/lib/sidebar-data.ts` to include a reference to this new page before merging. --- 🤖 **Need adjustments?** Leave a comment below and tag **@zitadel-knowledge-bot** with your requested changes, and I will automatically update the files and push a new commit! --------- Co-authored-by: zitadel-knowledge-bot[bot] <296006658+zitadel-knowledge-bot[bot]@users.noreply.github.com> Co-authored-by: Matías Racedo <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]> |
||
|
|
3c746281f1 |
fix(login): show account selection after RP-initiated logout (#12638)
**Supersedes #12471** by @conblem — same fix, rebased onto current `main` with conflicts resolved and review feedback applied. Original commits are preserved, all credit to @conblem. Closes #12252 ### Problem After an RP-initiated logout (`end_session` with `id_token_hint`), Login V2 redirected straight to `/loginname` instead of showing the account picker like Login V1. The `sessions` cookie still referenced the previous accounts, but their server-side sessions had been terminated, so `listSessions` returned nothing and the flow treated the browser as having no accounts at all. ### Solution - `handleOIDCFlowInitiation` now checks the `sessions` cookie (`hasCookieAccount`) when no live session is eligible for the request's organization. If the cookie still knows an account and the RP passed no `login_hint`, the user is sent to `/accounts` instead of `/loginname`. `prompt=none` is excluded since it must never render UI. - `/accounts` synthesizes an (invalid) `Session` for each cookie entry that has no live counterpart, so the account is still listed and a click re-authenticates via `/loginname` → password in one step. - `clearSession` prunes the cookie entry when the server reports the session as already gone (`NotFound` / `PermissionDenied`), so stale cards can be dismissed. Any other error is rethrown so a still-valid server session is never silently orphaned. - `getAllSessionCookieIds` is now a thin wrapper over `getAllSessions`; `FlowInitiationParams.sessionCookies` is typed as `Cookie[]`. - Tests: new `describe` block covering the cookie fallback, `login_hint` precedence, `prompt=none`, org-scope matching/mismatch, and `SELECT_ACCOUNT` / default prompt with live sessions filtered out by org. - Also adds `.claude/`, `.cursor/`, `.codex/`, `.windsurf/`, `.copilot/`, `.aider*` to `.gitignore`. --------- Co-authored-by: conblem <[email protected]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
6fdb74593c |
docs: remove outdated Actions v2 feature flag references (#12641)
# Which Problems Are Solved The docs ([Actions v2 concepts page](https://zitadel.com/docs/concepts/features/actions_v2) and the Actions testing guides) state that Actions v2 must be enabled via the "Actions" feature flag. That flag no longer exists — it is `reserved` in `proto/zitadel/feature/v2/instance.proto` and `system.proto` — and Actions v2 is available by default. The callout even linked to a non-existent API field. # How the Problems Are Solved - Removed the stale feature-flag callout (and the roadmap teaser callout) from `concepts/features/actions_v2.mdx`. - Removed the "Your ZITADEL instance needs to have the actions feature enabled." prerequisite bullet from the seven `guides/integrate/actions/testing-*.mdx` guides. Pure deletions, 17 lines across 8 files. # Additional Context The `SetInstanceFeatures` mentions in `hosted-login.mdx` and `webkeys.mdx` refer to flags that still exist and are untouched. |
||
|
|
7e8a3fb21d |
docs: add ZITADEL Cloud refund policy (#12577)
Adds a new public-facing Refund Policy to the documentation for ZITADEL Cloud subscriptions. This document clarifies billing expectations for customers and standardizes support operations by outlining: - **Scope:** Eligible situations for refunds (e.g., accidental overages, billing errors, accidental auto-renewals). - **Out of Scope:** Non-refundable items (e.g., Startup plan, standard usage-based fees, partial periods). - **Process:** Clear instructions on how customers can submit a refund request via the customer portal. --------- Co-authored-by: Maximilian <[email protected]> |
||
|
|
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]> |
||
|
|
324cc54d9a |
fix(console): support Zitadel Support IDP (#12619)
# Which Problems Are Solved
The backend and the new login already support the dedicated ZITADEL IdP
template
(`AddZitadelProvider`/`UpdateZitadelProvider`, `PROVIDER_TYPE_ZITADEL`,
`InstanceRolesInfo`),
but the Console does not expose it:
- A ZITADEL provider cannot be created or edited from the Console,
neither at instance nor at
organization level — it is missing from the "add provider" list in the
IdP settings.
- Providers created through the API are listed in the IdP table as
`coming soon` (the type
`@switch` has no `PROVIDER_TYPE_ZITADEL` case) and the row has no detail
route, so an existing
provider cannot be opened.
- `InstanceRolesInfo` — the organization ID/domain pairs that drive
instance-role synchronization
for instance-level providers — is not configurable outside the API.
# How the Problems Are Solved
- Added `ProviderZitadelComponent`
(`console/src/app/modules/providers/provider-zitadel/`),
modeled on the generic OIDC provider component:
- Form for `name`, `issuer`, `clientId`, `clientSecret` and `scopesList`
(chip input, defaulting
to `openid`, `profile`, `email`), plus the shared
`cnsl-provider-options` block.
- On edit the client secret is optional and is only sent when the
"update client secret"
checkbox is set.
- Reuses `cnsl-provider-next` for the callback URL, activation and "what
now" flow.
- Added the instance role mapping section, rendered only for
instance-level providers
(`serviceType === ADMIN`), since the API rejects `instance_roles_info`
on organization-scoped
ZITADEL providers. Entries are added as organization ID + organization
domain pairs and are
submitted as `InstanceRolesInfo`; both values are required and are
matched together on login.
- Added `addZitadelProvider`/`updateZitadelProvider` to `AdminService`
and `ManagementService`
wrapping the existing gRPC calls.
- Declared the component in `providers.module.ts` and mapped
`PROVIDER_TYPE_ZITADEL` to the
`zitadel` path in `providers-routing.module.ts`, so
`/instance/provider/zitadel/...` and
`/org/provider/zitadel/...` resolve for both create and edit.
- Added a ZITADEL tile to the "add provider" list in the IdP settings
and a
`PROVIDER_TYPE_ZITADEL` case to the IdP table;
`IdpTableComponent.routerLinkForRow` now returns
the detail route for ZITADEL rows.
# Additional Changes
- Added the new i18n keys `IDP.CREATE.ZITADEL.TITLE`/`DESCRIPTION`,
`IDP.INSTANCEROLESINFO`,
`IDP.INSTANCEROLESINFO_DESC`, `IDP.ORGANIZATIONID` and
`IDP.ORGANIZATIONDOMAIN` to all 22
Console locale files with actual translations instead of English
fallbacks.
- The provider tile, table entry and detail header use the colored
ZITADEL logo assets
(`zitadel-logo-solo-light.svg` in the dark theme,
`zitadel-logo-solo-dark.svg` in the light
theme), consistent with the header, rather than the monochrome
`*-solo-*-icon.svg` variants.
- Additional sync roles trigger for the support IDP to behave correctly
on first login after a disable/enable cycle — where auto-linking jumps
in — but not required for the link to be established.
# Additional Context
- Closes #11825
- Part of the "Zitadel as an Identity Provider" epic #5127
- Console counterpart to the API work in #12018, #12056 (add), #12371,
#12378, #12384 (update),
#12394 (get/list), #12396 (delete), #12422 (active IdPs) and to the
login support in #12469,
#12530 and #12568
---------
Co-authored-by: Livio Spring <[email protected]>
|
||
|
|
a1a4572303 |
chore(deps): update npm dependencies and clean up pnpm overrides (#12592)
# Which Problems Are Solved Several dependencies across the workspace had fallen behind their latest patch releases, and the override list in pnpm-workspace.yaml had drifted: many of the 28 entries were no longer needed because the dependency tree had already moved past them, while a few enforced outdated version floors. # How the Problems Are Solved Direct dependencies were updated where newer patch releases were available: `@angular/*` → ^21.2.19, `next` → 16.2.11 (docs and login, incl. `@next/eslint-plugin-next`), `nx` → 22.7.7, and `js-yaml` → ^4.3.1 / `postcss` → ^8.5.23 in docs. The lockfile was regenerated from scratch so all transitive dependencies resolve to the newest versions within their allowed ranges. With that in place, the override list was rebuilt down to the 8 entries that are still required because a parent package pins an older version (axios and brace-expansion via nx, postcss and sharp via next, esbuild via tsx, uuid via testcontainers, and the two OpenTelemetry packages); the other 20 overrides were removed. A verification pass confirmed the resolved versions stay at or above the floors the removed overrides used to enforce, and `pnpm audit` comes back clean. --------- Co-authored-by: Livio Spring <[email protected]> |
||
|
|
d488ecb07f |
fix(login): don't prepend base path to absolute IdP URL on login_hint redirect (#12610)
# Which Problems Are Solved When an OIDC authorize request carries a `login_hint` that resolves via domain discovery to an organization configured to auto-redirect to a single external IdP, Login V2's server-side flow initiation returns a malformed `Location` header: the login base path is concatenated with the absolute IdP authorize URL without a separator (e.g. `https://<instance>/ui/v2/loginhttps://login.microsoftonline.com/...`). The flow ends in `{"code":5,"message":"Not Found"}` and the IdP is never contacted. The same IdP works when the user submits the login name interactively, because the client-side handler distinguishes external URLs; only the server-side `login_hint` fast-path introduced in #12431 is affected. Additionally, the same fast-path only handled the `redirect` response shape from `sendLoginname`. When the discovered organization's IdP is SAML with POST binding, `sendLoginname` returns `samlData` (form fields instead of a URL), which was silently ignored — the user fell back to the prefilled `/loginname` screen instead of being signed in silently, unlike the client-side flow, which auto-submits the SAML AuthnRequest form. # How the Problems Are Solved `resolveLoginHint` passed every redirect returned by `sendLoginname` through `constructUrl`, which unconditionally prepends `NEXT_PUBLIC_BASE_PATH` and assumes a relative path. It now checks `isExternalUrl` first: absolute URLs are validated with `isSafeRedirectUri` (blocking `javascript:`/`data:`/etc., falling back to `/loginname` if unsafe) and redirected as-is, while relative paths keep being resolved against the base path — matching the handling already used in the idp-scope branch of `handleOIDCFlowInitiation` and in the client-side `handleServerActionResponse`. `resolveLoginHint` also handles the `samlData` response shape now: after validating the target URL with `isSafeRedirectUri`, it responds with the same auto-submit HTML form used by the existing SAML flows, so a `login_hint` resolving to a POST-binding SAML IdP completes silently as well. Since this form was previously duplicated inline three times in `flow-initiation.ts`, it is extracted into a shared `buildAutoSubmitFormResponse` helper used by all call sites. Regression tests cover the absolute-URL redirect (fails on the previous code with the exact malformed URL), the SAML POST auto-submit response, and the unsafe-scheme fallbacks for both shapes. |
||
|
|
632a519680 |
docs: fix redirect to WIP page (#12594)
Fixes the path for the temporary redirect for all /reference/api/group/* routes to a new "Under Development" page while the API is being built. |
||
|
|
d9a063ba5c |
chore: fix integration test (#12595)
A recent commit added an a faulty integration test. This PR fixes the corresponding test. |
||
|
|
a0e53933a2 |
docs: redirect /api/group/* to WIP page (#12593)
Adds a temporary redirect for all /api/group/* routes to a new "Under Development" page while the API is being built. --------- Co-authored-by: Copilot Autofix powered by AI <[email protected]> |
||
|
|
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]> |
||
|
|
a4266fa7fc |
Merge commit from fork
* fix(login): strip returnCode from browser supplied session challenges The updateOrCreateSession server action forwarded the browser's challenges object to the session API verbatim and returned the resulting challenges in its response. A party who knew only a victim's login name could create an identify-only session, request OTP-Email and OTP-SMS challenges with the returnCode delivery type, and read both plaintext codes straight out of the server action response. sanitizeChallenges forces returnCode off before anything is forwarded. It is applied in createSessionAndUpdateCookie and setSessionAndUpdateCookie rather than in the action itself, so every caller including sendPasskey is covered by one gate. An OTP-Email returnCode request is rewritten to sendCode rather than dropped, so the legitimate flow still completes and the code is delivered to the user; WebAuthN challenges carry no code and pass through untouched. The action response is also narrowed to the WebAuthN challenge. That is redundant while the sanitizer holds, but it keeps "no OTP code crosses this boundary" a local, readable property. Co-Authored-By: Claude Opus 5 <[email protected]> * test(login): assert session challenges are sanitized before reaching the API challenges.test.ts covers what sanitizeChallenges does, but nothing asserted that cookie.ts actually calls it. A refactor could drop either call site and the whole suite would still pass, silently reopening the hole. Covers both entry points to the session API by asserting on the challenges the mocked client receives, so the test fails if the sanitizer is ever bypassed. Co-Authored-By: Claude Opus 5 <[email protected]> --------- Co-authored-by: Claude Opus 5 <[email protected]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
5f3a0de33a |
docs: update knowledge gap ID 43 (#12590)
Automatically generated PR resolving Knowledge Gap ID 43.
**Thread ID:** mixpanel-cmvvwsc2uwvlkujw
**Action:** UPDATE
**Target Files:** 1
### AI Summary
> *Negative Docs Feedback*
*Path:* `/sdk-examples/react`
*Comment:* "ai generator went crazy it seems, with so many repeated &
unfinished sentences"
---
🤖 **Need adjustments?**
Leave a comment below and tag **@zitadel-knowledge-bot** with your
requested changes, and I will automatically update the files and push a
new commit!
Co-authored-by: zitadel-knowledge-bot[bot] <296006658+zitadel-knowledge-bot[bot]@users.noreply.github.com>
|
||
|
|
a331e5b7ba |
fix(login): redirect unknown users to external IdP after domain discovery when enumeration protection is active (#12581)
# Which Problems Are Solved A `login_hint` that resolves an organization via domain discovery but matches no existing user is no longer automatically redirected to the organization's external IdP when enumeration protection (`ignoreUnknownUsernames`) is active on the request context. #12369 combined the unknown-user IdP redirect into a single condition, but gated it with `&& !ignoreUnknownUsernames`: ```ts if ((!effectiveLoginSettings?.allowLocalAuthentication || discoveredOrganization) && !ignoreUnknownUsernames) { ``` Since the flag is resolved from the pre-discovery context (the instance default settings — during domain discovery there is no organization context yet), enabling enumeration protection instance-wide disables the redirect even for IdP-only organizations (`allowLocalAuthentication: false`). The flow falls through to `preventUserEnumeration()` and strands the user on a decoy Password screen for an account that does not exist; the Back button re-enters the auto-submit loop and dead-ends identically. This breaks silent-SSO first logins where the external IdP is supposed to create the account on first authentication (Login V2). The guard is also counterproductive on its own terms: existing users of the same org still auto-redirect to the IdP, so the divergent treatment acts as an enumeration oracle — redirect means the user exists, password screen means it doesn't. And it protects nothing in practice, since the IdP remains reachable manually via the account switcher → login-name screen → IdP button. # How the Problems Are Solved Split the condition by what known users of the resolved organization actually experience, so unknown usernames always mimic the known-user flow: ```ts if ( !effectiveLoginSettings?.allowLocalAuthentication || (discoveredOrganization && !ignoreUnknownUsernames) ) { ``` - **Local authentication disabled (IdP-only org):** redirect unknown usernames to the single allowed IdP regardless of `ignoreUnknownUsernames`. Known users are auto-redirected there, so the identical redirect is precisely what keeps unknown usernames indistinguishable — and it restores first-login account creation via the external IdP. - **Local authentication enabled:** known (password) users see the password screen, so the decoy password screen remains the indistinguishable behavior and enumeration protection keeps gating the discovery-based IdP redirect, as introduced by #12369. Adds regression tests for both cases: domain discovery into an IdP-only org with `ignoreUnknownUsernames: true` expects the IdP redirect; discovery into a local-auth org expects the decoy `/password` redirect with no IdP flow started. |
||
|
|
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]> |
||
|
|
790244dd49 |
feat(login): synchronize instance roles for Zitadel identity provider (#12568)
# Which Problems Are Solved - Logins through the new login via a Zitadel identity provider with `instanceRolesInfo` configured (e.g. support access) create/update the user, but the instance member roles from the `urn:zitadel:iam:org:project:roles` claim are never assigned — that synchronization only exists in the login v1 flow. Support users therefore end up without any permissions. # How the Problems Are Solved - New `syncInstanceRolesFromIdpIntent` in the login, called after user auto-creation and on existing-user logins. It mirrors the v1 filtering: roles are only honored for ZITADEL IdPs with `instanceRolesInfo`, when granted in a configured organization (matched on ID and domain) and using an instance role key (`IAM_` prefix). Memberships are written merge-only via the v2 `InternalPermissionService` (existing roles are never removed), and a failed sync logs a warning without blocking the login. # Additional Changes - Unit tests for the claim-to-role filtering. # Additional Context - Part of the "ZITADEL as an Identity Provider" epic: #5127 - Follow-up for PR #xxx (ZITADEL provider sign-in button in the new login) |
||
|
|
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. |