mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-17 16:35:14 -05:00
main
4589
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 <175728472+Copilot@users.noreply.github.com> |
||
|
|
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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * chore: trigger checks Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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
-
|
||
|
|
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. |
||
|
|
318bfc36d7 |
feat: native app links for passkeys (#12580)
# Which Problems Are Solved - Native iOS/Android passkeys need OS trust files on the ZITADEL domain; without them, Associated Domains / App Links verification fails. - Operators could not configure iOS Team ID + Bundle ID or Android package name + SHA-256 fingerprints on OIDC apps. - `/.well-known/apple-app-site-association` and `/.well-known/assetlinks.json` were not served from application config. # How the Problems Are Solved - Add iOS/Android app-link fields on OIDC app create/update (Application API v2 + Management), with validation. - Persist and project those fields; query active app-link configs instance-wide. - Serve AASA (`webcredentials`) and Digital Asset Links (`get_login_creds`) from well-known paths, with configurable `Cache-Control` and fingerprint normalization at serve time. - Console UI to edit the fields, with links to the well-known endpoints. - Operator docs for configuration, endpoints, caching, and verification. # Additional Changes - Document on API fields that well-known responses may be HTTP-cached and platform verifiers may delay propagation. - Runtime config: `WellKnown.AppLinksCacheControlMaxAge` (default `5m`; `0` → `no-store`). # Additional Context - Closes #12497 - Implemented and reviewed as stack: - #12531 API contract - #12532 storage wiring - #12536 well-known endpoints - #12537 console - #12547 docs --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
2bd42e8fc4 |
fix(actions): allow adding raw metadata values via appendMetadataRaw (#12567)
# Which Problems Are Solved Since #10666 (v4.1+, backported to v4.x), metadata values set through actions v1 (`api.metadata.push` and `api.v1.user.appendMetadata`) are always JSON-encoded via `json.Marshal`. This made the write path consistent with the JSON-based read path, but removed the ability to store raw (unencoded) metadata values: - A scalar string is now always stored quoted (`"de"` instead of `de`). - The previous byte-array convention (mapping a string to an integer array in the script, handled by `mapBytesToByteArray` introduced in #5526) now stores the literal integer-array text (e.g. `[100,101]`) instead of the raw bytes. Customers migrating from v3.x whose downstream systems base64-decode metadata values from tokens and expect raw bytes have no way to produce them anymore — changing the consuming system is not always possible. Reverting the default is not an option either, as clients that adopted actions v1 on v4.x now rely on the JSON encoding. # How the Problems Are Solved - Adds a new, opt-in function `api.v1.user.appendMetadataRaw(key, value)` to the actions v1 login flows (external / internal authentication post authentication and pre creation), next to the existing `appendMetadata`. - The value is stored as raw bytes without JSON encoding: - a string is stored as its plain UTF-8 bytes (`de`, not `"de"`) - byte arrays (`Uint8Array` or a plain array of integers 0-255, the old convention) are stored as-is, so existing v3 scripts using a string-to-byte-array helper only need to switch the function name - other types (and empty values) throw an error - The existing `appendMetadata` and `api.metadata.push` behavior remains byte-for-byte unchanged. # Additional Changes - Documented `appendMetadataRaw` (and the JSON encoding behavior of `appendMetadata`) in the external and internal authentication actions docs. - Added unit tests for the new function (through a real goja runtime) and a test locking in the existing `appendMetadata` JSON-encoding behavior. # Additional Context - Regression introduced as a side effect of #10666 (which fixed #10470); the raw byte handling was originally introduced in #5526. - Reported by a customer upgrading from v3.4.x to v4.16.x, whose PostAuthentication action maps token payload claims into user metadata. - Requires backport to v4.x. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ba3a45bee4 |
fix(login): treat user-verified passkey as fulfilling MFA in session validity check (#12575)
# Which Problems Are Solved
Users with a passkey plus an additional configured second factor (TOTP /
OTP Email / OTP SMS) get stuck on the passkey verification page: the
WebAuthn ceremony succeeds, but the continue button just spins and the
user is redirected back to `/passkey` indefinitely. Re-registering the
passkey or switching to a private window does not help.
Since the MFA hardening of `isSessionValid` (
|
||
|
|
30434d176c |
feat(login): Sign in with Zitadel (#12530)
Closes #11824 # Which Problems Are Solved The backend supports the dedicated ZITADEL identity provider template (`AddZitadelProvider`, `IDENTITY_PROVIDER_TYPE_ZITADEL`), but the new login (login v2) cannot yet handle providers of this type: A configured and activated ZITADEL IdP is not rendered as a sign-in option, because the provider type is missing from the login's component mapping. # How the Problems Are Solved - Regenerated `@zitadel/proto` (`settings/v2/login_settings_pb` in `types`, `es` and `cjs`) so `IdentityProviderType.ZITADEL = 13` is available (`idp/v2` was already up to date). - `apps/login/src/lib/idp.ts`: - `idpTypeToSlug` maps `ZITADEL` to the `zitadel` slug, used by the generic `/idp/[provider]/process|failure` routes (no route changes needed). - `idpTypeToIdentityProviderType` maps `IDPType.IDP_TYPE_ZITADEL` to `IdentityProviderType.ZITADEL`. - Added `SignInWithZitadel` button (`apps/login/src/components/idps/sign-in-with-zitadel.tsx`) with the Zitadel logo as inline SVG: the brand gradient is theme-independent, accents use `currentColor` to adapt to light/dark, consistent with the other provider buttons. - Registered the Zitadel type in the provider-to-component mapping in `sign-in-with-idp.tsx`. No changes to the IdP intent handling are required: user auto-creation/auto-update consume the pre-mapped `user_action.create_user` / `update_user` from `RetrieveIdentityProviderIntent`, which the backend fills for the Zitadel provider like for any generic OIDC provider. # Additional Changes - Added the `signInWithZitadel` i18n key ("Sign in with Zitadel") to all 15 locale files. - Extended `idp.test.ts` with the new slug and type mappings and added the Zitadel type to the exhaustive enum-coverage tests (45 tests passing). # Additional Context - Part of the "Zitadel as an Identity Provider" epic: #5127 - Enables the customer-portal "support access" flow, where support staff sign in to a customer instance via a pre-configured Zitadel Support IdP --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> |
||
|
|
83854e80e0 |
fix(telemetry): record route patterns instead of request paths on HTTP metrics (#12557)
# Which Problems Are Solved - The `uri` label of `http.server.request_count` and `http.server.return_code_counter` holds the concrete request path, so every object ID served over a REST path becomes its own metric series. On our v4.16.2 instance `/debug/metrics` exposes 8,000 series, 4,467 of which (56%) belong to `http_server_return_code_counter_total` alone, spread over 4,390 distinct `uri` values such as `/v2/sessions/385063742120926058`. The set only grows with the number of objects the installation has served. - Paths matching no route are recorded verbatim, so unauthenticated scanner traffic (`/wp-json/`, `/login.php`, …) keeps adding series. - The grpc-gateway answers `405 Method Not Allowed` without going through the configured `errorHandler`, so those responses never reported a route pattern, not even before the regression. This is a regression, first shipped in v4.11.0; v4.10.1 is unaffected. #9286 introduced the mechanism, #9523 extended it to unknown paths, and #11435 removed it while reorganising the middleware packages: > Removed setting of URI to context in metric middleware. There were only setters and no getters. (Unused value) The getter was `*recorder.RequestURI` in `RegisterRequestCounter` / `RegisterRequestCodeCounter`, which the same PR replaced with `baseURI(r)`. Both ends went at once, so nothing failed. # How the Problems Are Solved - `metrics.WithRequestURIPattern` / `metrics.SetRequestURIPattern` are back, and `metrics.RequestURI` is now the single place deciding what to label with: the pattern a router reported, or the requested path if none did. `UnknownPath` moved to the metrics package so every surface shares one constant. - The HTTP metrics middleware prepares the context before passing the request on, which is the half that got dropped. - `setRequestURIPattern` in the grpc-gateway reports the pattern to metrics again, not only to tracing, and is now also called on the `405` branch. - Requests routed by chi (the OIDC endpoints) fall back to the pattern chi matched. That covers the RFC 7592 client configuration routes added in #12315 (`/oauth/v2/register/{client_id}`), which are templated on the client ID, and collapses unknown paths below the OIDC prefixes to `UNKNOWN_PATH` as well. Against the metrics dump from our instance, `http_server_return_code_counter_total` drops from 4,467 series to roughly 35, and the endpoint as a whole from 8,000 to about 3,570. # Additional Changes - Regression tests, which were missing and are the reason the revert went unnoticed: - `metrics`: resolution of the `uri` label, including that a pattern set on a derived context reaches the middleware. - `gateway`: every way the gateway can answer — success, error, unroutable path, wrong method. - `middleware`: both routing styles, i.e. a router reporting its own pattern and a chi routed request. - A doc comment on `SetRequestURIPattern` explaining that it is written by routers and read back by the middleware through the context, so it does not read as an unused setter again. # Additional Context - Closes #12556 - Restores #9286 and #9523 - Regressed in #11435 Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
9fa0cad9d9 | docs: Update production guidelines (#12552) | ||
|
|
be5b278069 |
test(login): avoid assert.Never race in forged registration check (#12548)
# Which Problems Are Solved - `TestExternalNotFoundOption_ForgedRegistration_IsRejected` flakes across unrelated PRs: the test reaches PASS, then panics with `Log in goroutine after Test... has completed` and `rpc error: code = Canceled desc = context canceled`. # How the Problems Are Solved - Replaces `assert.Never` with a synchronous poll helper (`requireNeverUserByEmail`) so `ListUsers` and `require` run on the test goroutine and finish before `TestMain` cancels `CTX`. - Keeps the same “must stay absent for a window” polarity (not `Eventually`), so a late-projected forged user still fails the test. # Additional Changes - None. # Additional Context - CI examples: [run 30980062306](https://github.com/zitadel/zitadel/actions/runs/30980062306), [run 30837302974](https://github.com/zitadel/zitadel/actions/runs/30837302974) Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
0a18aba91c |
fix(database): skip CREATE USER when role exists to avoid password in Postgres logs (#12538)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Summary When `zitadel init` runs on every Helm upgrade/re-deploy, `VerifyUser` always executed `CREATE USER ... WITH PASSWORD '...'`. If the role already existed, PostgreSQL returned `42710` and logged the full statement—including the plaintext password—even though ZITADEL swallowed that error. This mirrors the existing `VerifyDatabase` catalog check: query `pg_roles` first and skip creation when the role exists. Also stops mutating the package-level `createUserStmt` when appending `WITH PASSWORD`. Fixes #12178 ## Changes - `cmd/initialise/verify_user.go`: check `SELECT EXISTS(... FROM pg_roles ...)` before `CREATE USER`; build password clause on a local statement copy - `cmd/initialise/verify_user_test.go`: cover catalog skip, catalog-check failure, and create paths with the new query ## Test plan - [x] `go test ./cmd/initialise/...` (passed) - [ ] Re-run init against an existing role and confirm Postgres logs no longer contain `CREATE USER ... WITH PASSWORD` <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-bc514793-d275-4d91-a71b-1b7566668b65?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-bc514793-d275-4d91-a71b-1b7566668b65&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 <cursoragent@cursor.com> Co-authored-by: Silvan <adlerhurst@users.noreply.github.com> |
||
|
|
464d1cb655 |
fix(login): resolve unknownContext error on password page caused by inconsistent ignoreUnknownUsernames handling (#12512)
# Which Problems Are Solved The `/password` page (and related password pages) can show the `unknownContext` error even though the user was resolved correctly and login completes fine. Two causes: 1. `sendLoginname` used two different `ignoreUnknownUsernames` sources: session creation was gated on the **user's org** login settings, while the redirect's `loginName` masking and enumeration guards were gated on a **caller-supplied flag** reflecting the request context (instance or org scope). Whenever the two diverge — e.g. `ignoreUnknownUsernames` enabled on the instance but not on the user's org, or the reverse — the redirect carries a `loginName` that doesn't match the session cookie (or no session is created at all), so the `/password` page can't load the session and renders `unknownContext`, while password submission still succeeds via its user-search fallback. This affects every caller of `sendLoginname` — the manual `/loginname` form as well as the server-side `login_hint` resolution; it was most reproducible with `login_hint`, since that path resolves context settings at the instance level, maximizing the chance of divergence with an org-level policy. 2. The password pages tried to re-derive "was enumeration protection applied?" from login settings to suppress the error — but they resolve settings from a different context than `sendLoginname` (falling back to the default organization instead of the instance), so any org-level custom policy made the suppression misfire. More fundamentally, the alert was keyed on a failed session lookup, which is not an error state: under enumeration protection no session exists by design, and the forms recover without one (`sendPassword` falls back to user search; `/password/set` works via code + userId). # How the Problems Are Solved `sendLoginname` no longer accepts `ignoreUnknownUsernames` from the caller. It derives a single constant server-side from the request-context login settings it already fetches and uses it for every decision in the flow: session creation, `preventUserEnumeration`, the multiple-users branch, the INITIAL-user check, the organization reset, the auth-method guards, and the IdP redirect after domain discovery. Redirect params use `session?.factors?.user?.loginName ?? command.loginName`: when a session exists, its loginName goes in the URL so the next page always finds the session cookie; when protection applies, no session (and no session cookie) is created and the raw input is echoed, keeping known and unknown users fully indistinguishable — including the previously observable `Set-Cookie` difference. `UsernameForm` and `resolveLoginHint` stop forwarding the flag, which also removes the redundant `getLoginSettings` call in `resolveLoginHint`. The password pages no longer consult login settings or the session lookup for the `unknownContext` error and instead warn only when required input is actually missing (matching the existing pattern on the passkey/mfa/u2f pages): `/password` and `/password/change` when no `loginName` searchParam is present, `/password/set` when neither `loginName` nor `userId` is present. A missing session alone no longer triggers the error. Verified against a live instance in all four settings combinations (uniform on/off, both divergence directions) including a known-vs-unknown `login_hint` comparison producing byte-identical responses under protection; full unit suite passes. --------- Co-authored-by: Livio Spring <9405495+livio-a@users.noreply.github.com> |
||
|
|
9070b537d2 |
feat(oidc): add RFC 7592 dynamic client management (#12315)
# Which Problems Are Solved #12313 lets clients register themselves as OIDC applications (RFC 7591), but a dynamically registered client has no way to manage itself afterwards: - The registration response returns no `registration_access_token` and no `registration_client_uri`, which RFC 7591 and 7592 clients (including the MCP SDKs) expect in order to manage their own registration. - There is no way to read back, update or delete a registration without an operator acting on the client's behalf through the Console or the Management API. A client that needs to rotate a redirect URI has to ask a human. # How the Problems Are Solved Implement the [OAuth 2.0 Dynamic Client Registration Management Protocol (RFC 7592)](https://datatracker.ietf.org/doc/html/rfc7592): `GET`, `PUT` and `DELETE` on `/oauth/v2/register/{client_id}`, wired through the same `op.WithSetRouter` hook as the registration endpoint. ## Control plane: the same security settings, no new switch The management endpoints are gated on the `DynamicClientRegistrationSettings.enabled` introduced in #12313. No new setting, no feature flag: a client that may register may manage what it registered. When registration is disabled the endpoints answer `404`, exactly as `POST /oauth/v2/register` does. `allow_unauthenticated` selects the registration mode and does not apply here, because the registration access token authorizes these endpoints in both modes. ## Authorization: the registration access token, and nothing else RFC 7592 §3 defines the credential as the registration access token issued at registration and bound to a single client, not a user credential. So a client manages only itself, proving it with the token it was handed: | | Registration (`POST`) | Management (`GET` / `PUT` / `DELETE`) | | --- | --- | --- | | Credential | Access token, or none in open mode | Registration access token | | Permission | `project.app.register_dynamic` in token mode | None | Neither `project.app.register_dynamic`, which governs who may *create* clients, nor `project.app.write` applies to the management endpoints. One authorization path per endpoint is easier to reason about than two, and there is no user identity to check against in the first place. Operators keep managing dynamically registered clients through the Management API and the Console, like any other application; they cannot obtain a client's registration access token, by design. Status codes: a missing, malformed or non matching token is `401`; a client that no longer exists is `404`. The token's `client_id` is checked against the path before any lookup, so a token bound to another client is rejected without touching the database. ## The token itself - **Stored like a client secret.** Only its passwap hash is persisted, through a new `project.application.config.oidc.registration_token.changed` event; the plain token leaves the server exactly once, in the response that issues it. - **Bound to its client and organization** through authenticated encryption with the instance key, the same mechanism as refresh tokens. The opaque token handed to the client encrypts `client_id:org_id:secret`, so the endpoint recovers the binding before checking the secret against the stored hash. - **Verified in O(1)** against the hash on the projection, folded into the client lookup the handler already does, with a strongly consistent fallback to the eventstore so a token that was just rotated is accepted before it is projected. - **Rotated on update** (RFC 7592 §4.4) and invalidated implicitly when the client is deleted. A read does not rotate it. Every update rotates, including one that changes no metadata, so a client always gets a usable token back. ## Reusing the existing persistence A small refactor extracts the shared OIDC application creation and update logic (`pushOIDCApplication` gains a variadic `extraEvents`, and `oidcApplicationChangeEvent` is lifted out of `UpdateOIDCApplication`), so the dynamic commands reuse it without the permission check. The registration token event is pushed atomically with the application, so a registered client is never left unmanageable. # Additional Changes - Setup step **74** adds the `registration_token` column to the `apps7_oidc_configs` projection, with a reducer for the new event modelled on the client secret. Applications are not part of the relational storage projection, so there is no second write path to keep in sync. - The registration response (`POST`) now also carries `registration_access_token` and `registration_client_uri`. - Docs: a "Manage a registration" section in the dynamic client registration guide covering the three operations, the authorization model, token rotation and the new limitations, plus the management endpoints on the OIDC endpoints page. - Unit tests for the command layer (issue, verify, rotate, update, delete), the projection reducer, and the token binding and response mapping. An integration test covers the full lifecycle (register, read, update with rotation, delete, read again with `404`), a token bound to another client, and the endpoints not being served when registration is disabled. None of the integration calls carries a user access token, which pins down that the management endpoints require no permission of their own. # Additional Context - Part of the MCP authorization effort in #9810. Follow-up to #12313 (RFC 7591), which is now merged; this branch has been rebased on `main` and carries the settings and authorization model agreed there. - **Open question for the review**: the authorization model above (registration access token only, no operator path through these endpoints) is the one I proposed when #12313 was reviewed, but it was never explicitly confirmed. If you would rather have `project.app.write` also grant access to a client in the operator's own organization, that is a contained change in `authorizeClientManagement`. - Rotation is eventually consistent: right after an update the previous token may keep working for a brief moment until the rotation is projected, while the new token works immediately through the eventstore fallback. This matches how ZITADEL validates access tokens, where revocation is likewise eventually consistent. It is documented in the guide and asserted as such in the integration test. - A database migration is introduced for the new projection column. No proto change and no new feature flag. - Verified locally: full build, `go vet` including `-tags integration`, `golangci-lint` with 0 issues, and the unit suites for every touched package. As before, I cannot run the integration suite here, so CI remains the check on it. |
||
|
|
a8861336f4 |
fix(api): accept SetSecuritySettings on /v2/settings/security (#12518)
# Which Problems Are Solved `GetSecuritySettings` is served at `GET /v2/settings/security`, but `SetSecuritySettings` was only bound to `PUT /v2/policies/security`. That split made the natural copy-paste path (`PUT /v2/settings/security`) return `405`, including the enable curl in the Dynamic Client Registration guide. # How the Problems Are Solved Make `PUT /v2/settings/security` the primary HTTP binding for `SetSecuritySettings`, and keep `PUT /v2/policies/security` as an `additional_bindings` entry so existing callers keep working. # Additional Changes None. # Additional Context - Noticed during review of the Dynamic Client Registration PR (#12313): the guide's enable curl used `PUT /v2/settings/security` and hit `405` against a live instance. - Only `settings/v2` is changed; `v2beta` is left as-is (deprecated). - Generated gateway/OpenAPI artifacts are gitignored and rebuilt from the proto on generate. Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
d35da1764d |
chore: Backlog cleanup/tag stale issues daily (#12522)
# Which Problems Are Solved Adds a daily scheduled workflow that tags issues stale at 12 months (no comment, reaction, or cross-reference in that window) with `To-be-closed` and posts the standard cleanup comment — same rule as the one-time sweep already run. Skips issues already labeled `To-be-closed` or `to-be-reviewed`. Tagging and commenting only — no auto-close. --------- Co-authored-by: Elina Sokolovska <elinasokolovska@Elinas-MacBook-Air.local> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Livio Spring <9405495+livio-a@users.noreply.github.com> |
||
|
|
8bbf0ad4e0 |
chore: move commented To-be-closed issues to to-be-reviewed (#12515)
# Which Problems Are Solved Adds a workflow that moves an issue from `To-be-closed` to `to-be-reviewed` once it gets a genuine reply (not counting our own auto-generated cleanup comments or bot comments). Runs two ways: - Instantly, when a comment is posted on an issue. - Daily, as a safety-net sweep of all `To-be-closed` issues, in case the instant check ever misses one. This is just the label-swap automation. The 30-day auto-close workflow is separate and not part of this PR. --------- Co-authored-by: Elina Sokolovska <elinasokolovska@Elinas-MacBook-Air.local> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Livio Spring <9405495+livio-a@users.noreply.github.com> |
||
|
|
dc1c8620bc |
fix(login): provide hidden username on password set/change forms (#12490)
<!-- Please inform yourself about the contribution guidelines on submitting a PR here: https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr. Take note of how PR/commit titles should be written and replace the template texts in the sections below. Don't remove any of the sections. It is important that the commit history clearly shows what is changed and why. Important: By submitting a contribution you agree to the terms from our Licensing Policy as described here: https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions. --> # Which Problems Are Solved The set-password, change-password and set-register-password forms render only password fields — there is no username field inside the `<form>`. Password managers look for a username field adjacent to the password field in order to associate and save the credential. Without one, on these screens they either skip offering to save, or save a password entry with no username attached — so the saved record can't later be matched to the account. The existing sign-in form (`password-form.tsx`) already carries a username field for this reason; the password *creation/change* flows were missing the equivalent. # How the Problems Are Solved Add a username field to each of the three forms so the newly set password is saved against the correct account: - `set-password-form.tsx` and `change-password-form.tsx` use the `loginName`. - `set-register-password-form.tsx` uses the `email`. The field is a real, focusable-excluded `type="text"` input rather than `type="hidden"` — password managers ignore `type="hidden"` fields when looking for a username, so a visually-hidden text input is required. It is: - `readOnly` (avoids a controlled-input warning and user edits), - `tabIndex={-1}` and `aria-hidden="true"` (kept out of the tab order and the accessibility tree), - `className="sr-only"` (visually hidden but present in the DOM), - `autoComplete="username"` (tells the password manager what it is). The value comes from props already passed to each form, so no signatures change. # Additional Changes None. # Additional Context - The username values (`loginName` / `email`) are already available in each component, so this is a purely presentational change with no server-side or API impact. Co-authored-by: Max Peintner <max@caos.ch> |
||
|
|
d725b9a0ed |
fix(console): change username abort dialog and set email as verified #10803 #12146 (#12155)
<!-- Please inform yourself about the contribution guidelines on submitting a PR here: https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr. Take note of how PR/commit titles should be written and replace the template texts in the sections below. Don't remove any of the sections. It is important that the commit history clearly shows what is changed and why. Important: By submitting a contribution you agree to the terms from our Licensing Policy as described here: https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions. --> # Which Problems Are Solved Cancelling the username change dialog in the console leads to an error toast #10803. Setting a users email as verified only worked if the email was also changed #12146. # How the Problems Are Solved Correctly destructure the abort response of the edit dialog. Use the update user endpoint instead of the set email endpoint to change the email of the user. # Additional Changes - Moved repetitive dialog code from rxjs to async await - Removed useless tests - Improved imports - Simplified phone number parsing and replaced deprecated function call # Additional Context Both changes were done in the same pr because the changes are in the same set of files. - Closes #10803 - Closes #12146 --------- Co-authored-by: Max Peintner <max@caos.ch> |
||
|
|
f34cc45eac |
feat(oidc): add RFC 7591 dynamic client registration (#12313)
# Which Problems Are Solved ZITADEL cannot currently be used as an OAuth 2.0 Authorization Server by clients that need to register themselves at runtime: - There is no [OAuth 2.0 Dynamic Client Registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591) endpoint, and no `registration_endpoint` is advertised in the discovery document. - This blocks [Model Context Protocol (MCP)](https://modelcontextprotocol.io) clients (Claude Desktop, claude.ai, Cursor, the MCP SDKs), whose OAuth 2.1 authorization profile expects an Authorization Server that supports dynamic client registration. They self-register before any user context exists and only then start the authorization code + PKCE flow. # How the Problems Are Solved ## Control plane: instance security settings No feature flag and no runtime config. A `DynamicClientRegistrationSettings` message is nested under the instance `SecuritySettings` in `settings/v2`, next to `embedded_iframe` and `enable_impersonation`: | `enabled` | `allow_unauthenticated` | Behaviour | | --- | --- | --- | | `false` (default) | — | `POST /oauth/v2/register` returns `404`, `registration_endpoint` absent from discovery | | `true` | `false` (default) | Token-gated registration, client homed in the token's organization | | `true` | `true` | Open registration for MCP hosts, client homed in the instance default organization | Both values are carried on `authz.Instance` and loaded with the instance, exactly like `EnableImpersonation`, so the discovery document and the endpoint read them per request without an extra query. `allow_unauthenticated` implies `enabled` in both implementations, so a single setting closes the endpoint. Adding the two columns bumps the projection table to `projections.security_policies3`, following what the impersonation setting did for `security_policies2`. ## Authorization: a dedicated permission, not `project.app.write` - New org-scoped permission **`project.app.register_dynamic`**, granted by default to `ORG_OWNER`, `IAM_OWNER` and `IAM_ORG_MANAGER` — in both `InternalAuthZ` and `SystemAuthZ`, mirroring where `project.app.write` already lives. - New built-in role **`ORG_DYNAMIC_CLIENT_REGISTRAR`** carrying only that permission, so a service user can self-register clients without gaining write access to the organization's existing applications. - Open mode (`allow_unauthenticated = true`) requires no token and no permission: that is the MCP self-register path. - A valid token whose user lacks the permission is answered with **`403 insufficient_scope`** (RFC 6750 §3.1) rather than `401 invalid_token`, so a caller can tell "your token is bad" from "your token may not do this". `verifyAccessToken` also reports invalid tokens as permission denied, so the authorization failure is marked explicitly to keep the two apart. The check runs once, at the endpoint, right after the token is verified and before any state is created, so it also gates the auto-provisioning of the organization's DCR project. The permission itself lives in the command layer next to the others (`Commands.CheckPermissionRegisterDynamicClient`). As the OIDC endpoints are not behind the authorization interceptor, the caller identity is derived from the verified token, the way token exchange does for the impersonating actor. ## Registration itself - **Registered clients are ordinary OIDC applications.** The endpoint reuses the existing application-creation events (`NewApplicationAddedEvent` and `NewOIDCConfigAddedEvent`), so the whole token, authorization and introspection flow keeps working unchanged. They are stored in a dedicated, auto-provisioned project named `ZITADEL DCR` per organization (name lookup), so they do not pollute the `IAMProject` or other projects. - Input is validated with the existing `GetOIDCV1Compliance` rules; `private_key_jwt` and `jwks`/`jwks_uri` are rejected with `invalid_client_metadata`. - **Endpoint wiring without forking the library.** `NewServer` calls `op.RegisterServer` instead of `op.RegisterLegacyServer`, so the new route can be added through the same `op.WithSetRouter` hook as the authorize callback. `op.RegisterLegacyServer` appends a middleware after the caller options, which chi forbids once a route has been registered; `op.NewIssuerInterceptor` reproduces the issuer middleware it would otherwise add. The `/oauth/v2` routing prefix is unchanged. # Additional Changes - **`application_type` inference for custom-scheme redirects** (last commit, isolated). `application_type` is an OpenID Connect member; RFC 7591 does not define it, so clients that only implement RFC 7591 omit it. Native MCP hosts doing so register custom-scheme redirect URIs, which ZITADEL reserves for native applications, and the OIDC default of `web` rejected them with `invalid_redirect_uri`. Inference is limited to custom schemes and to an absent `application_type`, so every request accepted today keeps its current application type and auth method — an `http` loopback redirect still yields a web application. Happy to drop this commit if you would rather keep it out of this PR. - **Docs**: a Dynamic Client Registration integration guide, a `registration_endpoint` section on the OpenID Connect endpoints page, and the new role in the administrators table (plus its description in the Console translations). # Additional Context - Part of #9810. This is **Phase 1 (DCR)**; #12315 adds RFC 7592 and #12316 adds CIMD, both stacked on this branch and rebased once this lands. - Implements the control-plane and authorization model requested in the review of this PR, itself a refinement of the direction in https://github.com/zitadel/zitadel/issues/9810#issuecomment-4891176997. - **Out of scope (deliberately)**: RFC 7592, CIMD, persisting free-form metadata (`logo_uri`, `contacts`, and similar), and `private_key_jwt` / `jwks_uri` (rejected cleanly). No new event types. - Follow-ups explicitly left out, per the review: DCR client lifecycle and richer audit, consent and custom scopes, JWT / `aud` (RFC 8707), and RFC 8414 AS metadata (currently absent from both ZITADEL and `zitadel/oidc`). |
||
|
|
adb2974662 |
fix: add the dropped idpTemplate argument (#12511)
# Which Problems Are Solved This PR fixes a Go compilation error in the Login V1 caused by a previously dropped `idpTemplate` argument when calling `registerExternalUser`. # How the Problems Are Solved - Pass `idpTemplate` into `registerExternalUser` from `handleExternalNotFoundOptionCheck` to match the function signature. # Additional Changes N/A # Additional Context introduced in https://github.com/zitadel/zitadel/commit/a17af0fe934ff94fcbf6c38d56aee308e1694962#diff-ec5370dd3f47f190c01e8dee4fd027b334a523cf31e5557422dd14c6e6c90339R859 |
||
|
|
10a66b786b |
fix(login): honour login_hint and skip the auto-submit loop in OIDC flow (#12431)
<!-- Please inform yourself about the contribution guidelines on submitting a PR here: https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr. Take note of how PR/commit titles should be written and replace the template texts in the sections below. Don't remove any of the sections. It is important that the commit history clearly shows what is changed and why. Important: By submitting a contribution you agree to the terms from our Licensing Policy as described here: https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions. --> # Which Problems Are Solved An OIDC authorize request that carries a `login_hint` (and no `prompt`) is meant to land the user on a **prefilled** login screen and let them proceed straight to authentication. Two problems prevented that: **1. The hint was dropped when an unrelated session existed.** When the browser held a `sessions` cookie for a *different* account: - The user was redirected to the **account picker** (`/accounts`) instead of the prefilled login-name screen. - The picker listed the already-logged-in, unrelated account(s) — never the user identified by `login_hint`. - The `login_hint` was **silently dropped** (the `/accounts` URL carries no hint), so the typed email was lost and the flow dead-ended on the wrong accounts. In a clean session (no `sessions` cookie) the same request prefilled correctly, which made the bug depend on unrelated pre-existing sessions. **2. The prefilled path was jarring and looped on "back".** Even when the hint *was* honoured, the flow redirected to `/loginname?loginName=…&submit=true`. That page renders the username screen and then **client-side auto-submits** it, so the user sees the username flash on screen, watches "Continue" click itself, and only then lands on the password step. Worse, `/loginname?submit=true` stays in browser history, so pressing **Back** from the password screen re-triggers the auto-submit and bounces the user straight forward again — an inescapable loop with no way to edit the username. # How the Problems Are Solved **Honour the hint (problem 1).** In `handleOIDCFlowInitiation`, the default branch (no `prompt`) already calls `findValidSession`, which filters the browser's sessions by `login_hint` and treats the hint as *selecting the user*. When the hint matches none of the sessions it returns `undefined`, and the remaining `eligibleSessions` (filtered by organization only) are therefore never the hinted user. The fallback previously chose between the loginname screen and the account picker based solely on `eligibleSessions.length === 0`, so a present-but-unrelated session sent the user to `gotoAccounts`, discarding the email. The loginname branch is now also gated on `authRequest.loginHint`, mirroring the existing empty-eligible-sessions branch. **Skip the flash and the loop (problem 2).** Instead of bouncing through `/loginname?submit=true` and relying on client-side auto-submit, the hint is now resolved **server-side** via `sendLoginname` (the same approach the `Prompt.LOGIN` branch already used) and the user is redirected **straight to the next step** (e.g. `/password`), skipping the `/loginname` screen entirely. This is factored into a shared `resolveLoginHint` helper and applied to all `login_hint` branches (default-prompt, no-session, and the existing `Prompt.LOGIN` path, now deduplicated). When the hint can't be resolved (unknown/ambiguous user, transient error), the flow falls back to a prefilled `/loginname` **without** `submit=true` — a clean screen where the user clicks once — so there is no auto-submit and no back-button loop in any path. ```ts if (!selectedSession || !selectedSession.id) { // login_hint matches no session: resolve it straight to the next step. if (authRequest.loginHint) { const hintResponse = await resolveLoginHint({ request, requestId, loginHint: authRequest.loginHint, organization }); if (hintResponse) { return hintResponse; } } // Prefill loginname (not the account picker) for an unresolved hint or when no session is eligible. if (authRequest.loginHint || eligibleSessions.length === 0) { return gotoLoginname({ request, requestId, loginHint: authRequest.loginHint, organization, orgDomain }); } return gotoAccounts({ request, requestId, organization, orgDomain }); } ``` Resulting behaviour: - `login_hint` set, a session **matches** it → silent auth (**unchanged**). - `login_hint` set, **no** session matches, hint **resolves** → straight to the next step (e.g. `/password`), no `/loginname` flash (**was**: account picker with the hint dropped / a jarring auto-submit screen). - `login_hint` set, hint **can't** be resolved → prefilled `/loginname`, no auto-submit, Back stays editable. - **No** `login_hint` → account picker / loginname exactly as before (**unchanged**). # Additional Changes - `resolveLoginHint` helper extracted in `flow-initiation.ts`; the `Prompt.LOGIN` branch now reuses it instead of duplicating the `sendLoginname` call. - `submit=true` is no longer emitted for the login-name flow, so the client-side auto-submit in `username-form.tsx` is no longer reached from OIDC initiation. - Tests in `flow-initiation.test.ts`: each `login_hint` branch (org-scope-filtered, unrelated-session, no-session) is covered twice — hint resolves → straight to `/password`; hint unresolved → prefilled `/loginname` with **no** `submit=true`. # Additional Context - The org-only `eligibleSessions` check was introduced in #12346, which correctly handled the *empty* case but did not account for a present-but-unmatched `login_hint`; this PR extends that gate. - Per OIDC, `login_hint` is advisory prefill; a client that wants the chooser would send `prompt=select_account`. Resolving the hint server-side also matches how the `Prompt.LOGIN` branch already behaved. --------- Co-authored-by: Max Peintner <peintnerm@gmail.com> Co-authored-by: Max Peintner <max@caos.ch> |
||
|
|
a17af0fe93 |
Merge commit from fork
* fix(login): prevent external-IDP account pre-hijack in Login V1 (GHSA-738m-7888-jfv8) The Login V1 endpoint POST /ui/login/externaluser/option (handleExternalNotFoundOptionCheck) built the new external user's identity and "verified" flags directly from raw POST fields, without checking the server-side authReq.LinkingUsers state that a genuine, protocol-verified IDP callback populates. An unauthenticated attacker could forge that POST to create a real account bound to an arbitrary (IDPConfigID, ExternalUserID) - enabling account pre-hijack of a third party whose public external identifier (e.g. a GitHub numeric user id) was used, so the victim's own future "Sign in with X" logs into the attacker's pre-created account. Primary fix: - Reject the request when authReq.LinkingUsers is empty, mirroring the guarded sibling autoCreateExternalUser. - Source IDPConfigID/ExternalUserID and the email/phone "verified" flags from the trusted linkingUser (the real callback data) instead of the form; the verified flag only survives when the editable value still matches the IDP's. Profile fields (name, nickname, language, edited email/phone) still come from the form, which is the page's legitimate purpose. Secondary hardening: - Treat the OAuth error/error_description callback params as untrusted so a forged GET .../externalidp/callback?error=... can no longer trigger the local-auth fallback (RequestLocalAuth) and undermine the IDP-first login priority. Genuine code-exchange failures keep their existing handling. Tests: - Unit test for mapExternalNotFoundOptionFormDataToLoginUser proving forged identity/verified fields are ignored in favor of the trusted callback data. - A //go:build integration end-to-end reproduction driving the forged registration over raw HTTP and asserting the account is not created. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(login): handle IDP error callback for logging, keep local-auth fallback Addresses review feedback (livio-a) on GHSA-738m-7888-jfv8: the OAuth error callback is not a security boundary. Blocking only the error= path added no protection (a garbage code triggers the same fallback), the downgrade only affects the attacker's own auth request, and a genuine IDP error on the callback should still fall back to local auth. - handleExternalLoginCallback: on an IDP error response, log it with context and route through externalAuthCallbackFailed so local-auth fallback still happens when the login policy allows it (previous behavior), instead of rendering a plain login error. The displayed message reuses the existing Errors.User.ExternalIDP.LoginFailedSwitchLocal via WrapIdPError. - Drop the now-unused Errors.ExternalIDP.AuthenticationFailed translation (would otherwise need all-language entries) and regenerate the login statik blob. - Reword the externalIDPCallbackData Error/ErrorDescription comment: logging-only, not a security decision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Livio Spring <9405495+livio-a@users.noreply.github.com> |
||
|
|
fe935d91b8 | Merge commit from fork | ||
|
|
6fa5c0e662 |
Merge commit from fork
* feat(login): add shared enrollment authorization guard Introduce assertSessionAuthorizedForEnrollment, a single gate that decides whether a session may enroll a new authenticator: it requires a verified primary factor (password, passkey or IDP) on a non-expired session, or, for onboarding, a user with no auth methods plus a prior user-verification check. This mirrors the gate previously inlined only in registerPasskeyLink, which is refactored to reuse the shared helper. It is the foundation for gating the remaining credential-enrollment entry points (GHSA-45f2-5q3r-xgg6). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(login): require authentication before WebAuthn/U2F enrollment addU2F and verifyU2F registered a credential for any session that resolved to a user id, including a bare identify-only session created by merely submitting a login name. An unauthenticated attacker who knew a victim's login name could therefore attach an attacker-controlled authenticator to the victim's account and then complete a login as the victim. Both actions now run assertSessionAuthorizedForEnrollment before registering or verifying, so an unauthenticated session is rejected and no credential is persisted. GHSA-45f2-5q3r-xgg6 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(login): require authentication before TOTP/OTP enrollment verifyTOTP and the otp/<method>/set page activated a TOTP/OTP factor for any session with a user id, and the u2f/set page rendered the registration form without an authorization check. An identify-only session could thus enroll a second factor on a victim's account. Gate all of them with assertSessionAuthorizedForEnrollment. The setup pages now render the unknown-context/error state instead of enrolling when the session is not authorized. Also guard the method value in the OTP set page, since the added guard branch removes the control-flow narrowing the previous throw provided. GHSA-45f2-5q3r-xgg6 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(login): require authentication before legacy (V1) passwordless enrollment The V1 passwordless registration handlers began and verified passwordless setup for any auth request that carried a user id, without checking that a first factor had been verified. An unauthenticated attacker could submit a victim's login name and then POST directly to the passwordless prompt/init endpoints to plant an attacker-controlled passkey, then log in as the victim. Add passwordlessSetupAllowed / CheckPasswordlessSetupAllowed, which permit enrollment only for a legitimate onboarding user (PasswordlessInitRequired) or an auth request whose first factor is already verified, and call it before BeginPasswordlessSetup and VerifyPasswordlessSetup. The verify path is also bound to authReq.UserID instead of the attacker-controllable form field, so a credential cannot be redirected onto a different account. The init-code (mail/onboarding) path is unchanged. GHSA-45f2-5q3r-xgg6 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(login): remove never-active legacy (V1) authReq passwordless setup path Supersedes the runtime gate added in the previous commit. Maintainer review (Livio, the original author) and investigation confirmed the auth-request based passwordless *setup* path in the legacy login was a never-active leftover: - The passwordless prompt page (passwordless_prompt.html) has no submit button and no script; it only tells the user to complete setup via the emailed link (the PasswordlessButtonText/NextButtonText/SkipButtonText i18n keys are unused). - Legitimate onboarding for a PasswordlessInitRequired user goes through the emailed init-code flow (authReq == nil, BeginPasswordlessInitCodeSetup / VerifyPasswordlessInitCodeSetup), which is untouched. - The authReq branch (handlePasswordlessPrompt -> BeginPasswordlessSetup / VerifyPasswordlessSetup) was reachable only by a crafted direct POST — the GHSA-45f2-5q3r-xgg6 attack. Rather than gate the dead path, remove it entirely, eliminating the attack surface: - delete handlePasswordlessPrompt and the POST /login/passwordless/prompt route (keep renderPasswordlessPrompt: the informational "use your emailed link" page for PasswordlessRegistrationPromptStep), - make renderPasswordlessRegistration / checkPasswordlessRegistration use only the init-code path, - drop the now-unused AuthRequestRepo.BeginPasswordlessSetup / VerifyPasswordlessSetup (and the interface decls) and the passwordlessPromptUrl template helper; the underlying HumanAddPasswordlessSetup / HumanHumanPasswordlessSetup commands remain (used by the auth-API self-service and the v2 passkey APIs). A crafted POST to /login/passwordless/init carrying an authRequestID but no valid code now falls through to the init-code path and fails. GHSA-45f2-5q3r-xgg6 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * check for userverified true as well, cleanup return message --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Max Peintner <peintnerm@gmail.com> |
||
|
|
ffd9b39bdb |
feat: handle Zitadel provider IdP login via login v1 (#12469)
# Which Problems Are Solved This PR adds support for using a ZITADEL IdP provider in the login v1 UI flow and conditionally grants a support-user instance membership based on a ZITADEL-specific project-roles token claim. # How the Problems Are Solved - Introduced ZITADEL IdP handling in the login v1 handler, including extracting the `urn:zitadel:iam:org:project:roles` claim and granting `IAM_OWNER_VIEWER` instance membership when configured orgs match. - Added a new `AddInstanceMemberFromLogin` to assign an instance membership during the login v1 ZITADEL-IdP flow without permission checks (as the authorization is established based on the project roles in the token claim) - Added unit tests # Additional Changes N/A # Additional Context - Closes https://github.com/zitadel/zitadel/issues/12446 |
||
|
|
318acd4bc3 |
perf(query): speed up ListUsers login name equality filters (#12460)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Summary `ListUsers` with `LoginNameQuery` + equals/equals-ignore-case was very slow on large orgs (e.g. ~330k users) because the generated SQL filtered the `projections.login_names3` **view** on the computed `login_name_lower` expression and correlated that subquery on `users14.id`. Postgres nested-looped every user and never used `login_names3_users_search (instance_id, user_name_lower)`. This change rewrites the **query planner** for that hot path: when an equals/equals-ignore-case login-name filter is present (and not under `OR`/`NOT`), the user list query **INNER JOINs** an indexed matches subquery instead of filtering via the view expression. The matches SQL mirrors `user_by_login_name.sql` (`user_name_lower` / domain paths + `preferred` / `is_primary`). Non-equals methods and OR combinations keep the previous view-based filter so semantics stay unchanged. Also adds a k6 use case that mirrors login v2 discovery (`loginNameQuery` EQUALS_IGNORE_CASE + `organizationIdQuery`, `limit: 2`): ```bash cd benchmark make users_by_login_name USER_AMOUNT=100000 VUS=10 DURATION=60s ``` ## Approach 1. `NewLoginNameSearchQuery` for equals / equals-ignore-case returns a marker `loginNameEqualsFilter` (other methods unchanged). 2. `prepareUsersQuery` extracts that marker when safe, then: - builds the usual `sq.SelectBuilder` **without** the login-name view predicate - adds `JoinClause` to `user_login_name_matches(.sql)` / `_case_sensitive.sql` as `login_name_matches` - keeps metadata JOIN/`DISTINCT` only when metadata filters are present (same as before) 3. Embedded SQL files under `internal/query/` for the matches subquery. Local smoke against ~330k synthetic users: baseline ~1392ms → rewritten path ~0.4ms for a single equals-ignore-case lookup. ## Test plan - [x] `go test ./internal/query/ -run 'TestLoginName|TestUsers|TestUserByLoginName'` (after generate-stubs) - [x] Existing `user_test` expected SQL updated (no always-on metadata join; login-name equals uses JOIN) - [ ] Run k6 before/after on a large `USER_AMOUNT` (e.g. 50k–100k+) and compare `list_users_duration` p50/p95/p99 - [x] Manual login v2 username discovery against a large org - [x] Confirm OR / NOT / CONTAINS login-name queries still return expected results <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-252883bd-48d9-492e-b619-5ccfa93cf9c3"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-252883bd-48d9-492e-b619-5ccfa93cf9c3"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Silvan <adlerhurst@users.noreply.github.com> |
||
|
|
87538f29e1 |
docs: prefix legacy V1 API paths in generated OpenAPI (#12491)
# Which Problems Are Solved
- Generated V1 API reference docs are missing their base path prefixes
(e.g. `/admin/v1`, `/auth/v1`, `/management/v1`, `/system/v1`).
- Endpoint pages show routes like `/healthz` instead of
`/admin/v1/healthz`.
This happens because V1 protos still define `base_path` via legacy
OpenAPI v2 swagger annotations, while docs generation uses
`protoc-gen-connect-openapi`, which ignores those annotations and emits
the bare `google.api.http` paths.
# How the Problems Are Solved
- After OpenAPI generation in
`apps/docs/scripts/generate-proto-docs.mjs`, apply a small hardcoded map
of the four legacy V1 services and prepend the correct prefix to each
path in the generated specs.
- The rewrite is idempotent (`startsWith(prefix)`), so already-prefixed
paths are left alone.
- Because the prefixes are written into the generated OpenAPI files,
Fumadocs MDX generation and runtime page rendering stay in sync.
# Additional Changes
- None.
# Additional Context
- V1 APIs are legacy and frozen; hardcoding the four known prefixes is
simpler and more reliable than dynamically scraping `.proto` files.
- Changing the protos or dual-generating with `openapiv2` would be much
more invasive for a docs-only issue.
- Verified locally for Admin, Auth, Management, and System pages (e.g.
`/admin/v1/healthz`, `/management/v1/users/{id}`).
---------
Co-authored-by: Livio Spring <9405495+livio-a@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
||
|
|
ca6595f8c5 |
chore: google.golang.org/grpc deps update (#12484)
# Which Problems Are Solved google.golang.org/grpc < 1.82.1 had a high severity vuln reported here: https://github.com/zitadel/zitadel/security/dependabot/784 . Needs to be addressed # How the Problems Are Solved `go get -u google.golang.org/grpc` # Additional Context - Closes https://github.com/zitadel/zitadel/security/dependabot/784 |
||
|
|
bb4f546b7c |
docs: add video walkthrough link to quickstart guide (#12479)
Link the YouTube walkthrough at the end of the quickstart page for users who prefer watching over reading. Co-authored-by: Rajat Singh <rajat@zitadel.com> |
||
|
|
62e56e085c | docs: fixed broken docs references (#12465) | ||
|
|
d78ed6b5f2 |
fix: improve random string generation (#12266)
# Which Problems Are Solved `GenerateRandomString` used for generating codes incl. OTP was incorrectly ignoring the last rune of the possible set. # How the Problems Are Solved Refactored the function to get the full randomness. # Additional Changes None # Additional Context Thanks @AyushParkara for pointing this out. |
||
|
|
3e94047da7 |
fix(login): redirect to external IdP after domain discovery regardless of registration policy (#12369)
Closes #12021 Closes #12023 # Which Problems Are Solved When a user entered an email on the login page and domain discovery resolved the correct organization, the login flow failed to redirect to the configured external IdP. Instead it returned a "user not found" error or showed the registration page. This happened because the "user not found" decision tree in `sendLoginname` only attempted an IdP redirect when `allowLocalAuthentication` was disabled. If local auth was enabled but `allowRegister` was `false`, the IdP check was skipped entirely. # How the Problems Are Solved Combined the IdP redirect logic into a single block that triggers when either local authentication is disabled **or** domain discovery resolved an organization. The registration policy (`allowRegister`) now only affects local account creation and no longer gates external IdP authentication. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Livio Spring <9405495+livio-a@users.noreply.github.com> |
||
|
|
2b9b9856b4 |
docs: remove changelog link from sidebar (#12459)
The changelog page on zitadel.com was removed for being unmaintained, leaving the docs sidebar link 404ing. Co-authored-by: Rajat Singh <rajat@zitadel.com> |
||
|
|
437a0802bd |
docs: document initial admin password requirements in compose guide (#12452)
# Which Problems Are Solved - The **Harden secrets** section of the Docker Compose guide lists commands for the masterkey and the two database passwords, but never mentions the **initial admin user password**. - Setting `ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD` to a value that does not meet the default password complexity policy fails the initial setup with a `_password_complexity_model` error, and the requirements were not documented anywhere near the other secrets. # How the Problems Are Solved - Adds a short note directly below the secret-generation commands documenting the initial admin password: the default (`Password1!` / `zitadel-admin@zitadel.localhost`), how to override it via `ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD`, and the default complexity requirements (min 8 chars, upper + lower + number + symbol). - Links to the [Password Complexity](/guides/manage/console/default-settings#password-complexity) policy docs and notes that the variable only applies during initial setup. # Additional Changes None. # Additional Context - Reported by a self-hosting user (TrueNAS + Ansible) who hit repeated setup errors because the initial admin password complexity requirement was not documented alongside the masterkey/DB password commands. |
||
|
|
b91221143b |
feat: extend start/retrieve idp intent for Zitadel provider (#12424)
# Which Problems Are Solved This PR extends `StartIdentityProviderIntent` and `RetrieveIdentityProviderIntent` for the new Zitadel provider. # How the Problems Are Solved - Added `domain.IDPTypeZitadel` cases to both the instance and org switches in `NewAllIDPWriteModel` to return Zitadel provider when `GetProvider` is called - Added Zitadel provider to RetrieveIdentityProviderIntent's provider type-switch - Added new `zitadel.go` provider, an OIDC wrapper with forced PKCE - Added unit/integration tests # Additional Changes # Additional Context - Closes https://github.com/zitadel/zitadel/issues/12050 --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> |
||
|
|
53d7920349 |
fix(console): display minimum length in password complexity message (#12419)
# Which Problems Are Solved
During user registration and password changes, the console's password
complexity hint displayed the raw `{{value}}` placeholder instead of the
configured minimum length — e.g. `Must be at least {{value}} characters
long (0/8)`. The bug is language-independent (confirmed in English and
Dutch).
# How the Problems Are Solved
The `password-complexity-view` template passed `policy.minLength` — a
proto `uint64` that deserializes to a JavaScript `bigint` — to the
ngx-translate pipe. ngx-translate v17's `formatValue` returns
`undefined` for a `bigint`, so the `{{value}}` token was never
substituted. The template now passes the component's existing
`minLength` getter (`Number(policy.minLength)`), so the value
interpolates correctly.
# Additional Changes
None
# Additional Context
- Closes #12390
- Reported on ZITADEL Cloud v4.15.3
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Ramon <mail@conblem.me>
Co-authored-by: Livio Spring <9405495+livio-a@users.noreply.github.com>
|
||
|
|
56f4798ed3 |
Merge commit from fork
* add mfa check to session validity check * change session check to check for already set up mfa methods * lint * improve naming * test changes * cleanup continueWithSession fcn |
||
|
|
baf6ed501b |
Merge commit from fork
* fix(actions): prevent disk access via require * add test to ensure native modules are still possible |
||
|
|
94ac4237a9 |
docs: add user self-deletion guide (#12458)
## Summary - Add a new guide documenting how to let users delete their own account via the User v2 API's `DeleteUser` endpoint, gated by the `user.self.delete` permission (granted via `ORG_USER_SELF_MANAGER`) - Link the new guide from the self-service concepts page and sidebar - Add the `ORG_USER_SELF_MANAGER` role to the administrators reference table ## Test plan - [x] Verified the new and updated pages render correctly in local dev (`pnpm dev`) replaces #12455 and #12457 (branch got renamed from `docs/human-pat-clarification`, which auto-closed #12457). Co-authored-by: Rajat Singh <rajat@zitadel.com> |
||
|
|
75bc058bce |
feat(eventstore): autovacuum tuning for events2 table (#12449)
# Which Problems Are Solved
In Zitadel's append-only event-sourced architecture, the
`eventstore.events2` table grows indefinitely. PostgreSQL's default
autovacuum uses a percentage-based scale factor, so as the table grows,
the number of changed rows required to trigger a `VACUUM` or `ANALYZE`
drifts towards infinity. Without regular vacuums, the table's Visibility
Map becomes stale, disabling fast Index-Only Scans and forcing expensive
heap reads. Without regular analyzes, query planner statistics become
stale, leading to suboptimal execution plans.
This causes eventstore operations to progressively degrade as `events2`
grows, even without CPU, memory, or I/O saturation. A manual `VACUUM
ANALYZE` immediately restores performance, confirming the root cause.
- If `Eventstore.Autovacuum` is left at its default, `events2` keeps
using PostgreSQL's default, percentage-based autovacuum/autoanalyze
scale factors, which become impractically infrequent on large tables.
- There was previously no supported way to apply static, table-level
autovacuum tuning to `events2` through Zitadel's own configuration/setup
process.
# How the Problems Are Solved
- Added an `Eventstore.Autovacuum` runtime configuration block to
`cmd/defaults.yaml` (disabled by default):
```yaml
Eventstore:
Autovacuum:
Enabled: false # ZITADEL_EVENTSTORE_AUTOVACUUM_ENABLED
VacuumThreshold: 50000 # ZITADEL_EVENTSTORE_AUTOVACUUM_VACUUMTHRESHOLD
AnalyzeThreshold: 50000 # ZITADEL_EVENTSTORE_AUTOVACUUM_ANALYZETHRESHOLD
```
- Added a repeatable `zitadel setup` migration step
(`cmd/setup/eventstore_autovacuum.go`) that:
- When `Enabled: true`, disables the percentage-based
`autovacuum_vacuum_scale_factor`, `autovacuum_analyze_scale_factor`, and
`autovacuum_vacuum_insert_scale_factor` on `eventstore.events2`, and
applies static thresholds (`autovacuum_vacuum_insert_threshold`,
`autovacuum_vacuum_threshold`, `autovacuum_analyze_threshold`) from the
config instead.
- When `Enabled: false`, resets those storage parameters on
`eventstore.events2` back to the cluster defaults.
- Implements `Repeatable.Check()` so the step only re-runs when the
configuration actually changed since the last `zitadel setup` run.
- Added documentation in the new "Performance tuning" page.
# Additional Changes
- Move the projection documentation into performance tuning page
- Expand and update the projection documentation to the latest state in
zitadel. (contained some stale information)
# Additional Context
Several other issues describe read-performance symptoms consistent with
this same root cause (stale `events2` visibility map / planner
statistics at scale, without resource saturation). Since this PR
addresses the shared root cause:
- Closes #12448
- Closes #10754
- Closes #10260
- Closes #8585
- Closes #9239
---
_Generated by [Claude
Code](https://claude.ai/code/session_01HoKvEY7niCVgLCz7CajBwW)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com>
|