Commit Graph
4602 Commits
Author SHA1 Message Date
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>&nbsp;<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>&nbsp;</div>

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Silvan <[email protected]>
2026-08-28 15:20:28 -03:00
8e442ed4bc fix(oidc): allow OrgRoleIDScope downscoping in token exchange (#12563)
# Which Problems Are Solved

- Token exchange rejects `urn:zitadel:iam:org:roles🆔{orgID}` when
that scope is not already present on the subject or actor token
(`invalid_scope`).
- A documented least-privilege pattern — exchanging a broad access token
for one whose roles claims are limited to a single organization — has
been broken since the v4.15.3 token-exchange hardening and was not
restored by #12312.
- A naive allow-any-`roles:id` exception would let a previously narrowed
token widen again (switch org filter, or drop the filter and get all
role orgs), including via an actor token's filter on actor-path
exchanges.
- A client-requested bare `urn:zitadel:iam:org:roles🆔` (empty org id)
is not a meaningful filter and should be rejected on exchange.

# How the Problems Are Solved

- Treats `OrgRoleIDScope` as a restriction filter with downscope-only
semantics via `applyOrgRoleIDScopeDownscoping(requested,
ceilingScopes)`:
- union / standard exchange passes the **subject** token scopes as the
ceiling (actor filters ignored)
- impersonation (scopeless `user_id` / `id_token` subject) passes the
**actor** token scopes as the ceiling
- ceiling unfiltered → any requested `roles:id` is allowed (broad →
narrow)
  - ceiling filtered → requested filters must be ⊆ that set
- ceiling filtered but request omits them → inherit the ceiling (omit
must not re-widen)
- Rejects a **client-requested** bare `urn:zitadel:iam:org:roles🆔` in
`validateTokenExchangeScopes` after normalize (before empty-request
fallback). Input tokens may still carry the bare prefix as a degenerate
ceiling; empty-request / omit inherit does not re-trigger the rejection.
- Keeps non-restriction scopes on the existing subject ∪ actor presence
checks.
- Adds unit coverage for downscope, further downscope, widen rejection,
omit-inherit, mixed subject/actor filters, empty-scope access/JWT
subjects, and bare-prefix request/ceiling cases.
- Adds an integration test for broad → `roles:id` filtered roles, plus a
second exchange that rejects widening to another org.
- Documents downscope-only and omit-inherit behavior in the reserved
scopes table and the token exchange guide.

# Additional Changes

- Updates the token-exchange guide Scope section to reflect that
authorization-related reserved scopes must already be present on input
tokens (aligned with the v4.15.3 hardening), rather than suggesting
scope can be freely extended.

# Additional Context

- Closes #12413
- Follow-up for PR #12312
- Related discussion in #11869 (recommended `roles:id` workaround; this
restores that path for token exchange without allowing filter widening)
- Behavior note for release notes: during token exchange, an existing
`urn:zitadel:iam:org:roles🆔*` filter on the subject is inherited when
omitted from the request, so role claims cannot re-widen.

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Livio Spring <[email protected]>
2026-08-28 16:27:56 +02:00
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]>
2026-08-28 15:53:57 +02:00
711d688294 fix(login): resolve the registration org consistently with the login policy (#12621)
# Which Problems Are Solved

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

# How the Problems Are Solved

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

# Additional Changes

Added a unit test covering the resolution order.

# Additional Context

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

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-28 10:37:52 +00:00
zitadel-knowledge-bot[bot]GitHubzitadel-knowledge-bot[bot] <296006658+zitadel-knowledge-bot[bot]@users.noreply.github.com>Matías RacedoCopilot Autofix powered by AI
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]>
2026-08-27 17:13:04 -03:00
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]>
2026-08-27 11:07:30 +00:00
6128aeaa89 fix(security): prevent TOTP reuse (#12616)
# Which Problems Are Solved

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

# How the Problems Are Solved

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

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

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

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

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

# Additional Changes

- Read only the latest events in recheck query

# Additional Context

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

---------

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

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

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

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

# How the Problems Are Solved

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

# Additional Changes

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

# Additional Context

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

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-26 10:57:28 +02:00
Marco A.andGitHub 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.
2026-08-25 16:58:43 +02:00
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]>
2026-08-25 12:09:43 +00:00
3c3bd2bc57 fix(api): correct caching and relations of well-known app link files (#12634)
# Which Problems Are Solved

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

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

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

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

# How the Problems Are Solved

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

# Additional Changes

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

# Additional Context

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

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

---------

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

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

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

* fix: reject claim serving for users in deactivated organizations

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

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

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

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

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

* fix: reject API tokens from deactivated organizations

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

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

* fix: invalidate OIDC refresh tokens after org deactivation

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

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

* fix: invalidate V2 access tokens after org deactivation

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified by running the three builds against a real instance:

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

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

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

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

* chore: trigger checks

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-14 06:11:39 +02:00
zitadel-knowledge-bot[bot]GitHubzitadel-knowledge-bot[bot] <296006658+zitadel-knowledge-bot[bot]@users.noreply.github.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>
2026-08-12 15:12:20 -03:00
Max PeintnerandGitHub 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.
2026-08-12 13:41:58 +02:00
6127b567e7 feat(oidc): provide actor information in userinfo actions (#12566)
# Which Problems Are Solved

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

# How the Problems Are Solved

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

# Additional Changes

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

# Additional Context

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

---------

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

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

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

# How the Problems Are Solved

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

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

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

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

# How the Problems Are Solved

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

# Additional Changes

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

# Additional Context

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

---------

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

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

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

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

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

# How the Problems Are Solved

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

# Additional Changes

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

# Additional Context

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

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

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-11 15:44:15 +02:00
Max PeintnerandGitHub 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` (56f4798), a session is only
considered valid if one of the user's *configured* MFA methods (TOTP,
OTP Email, OTP SMS, U2F) was verified within the session. A passkey
login sets `factors.webAuthN.verifiedAt` with `userVerified: true`, but
only the U2F method reuses that factor — so a passkey session for a user
whose second factor is TOTP or OTP is deemed invalid at flow completion.

This contradicts the login flow itself: `checkMFAFactors` intentionally
never prompts for a second factor after a passkey login ("escape further
checks if user has authenticated with passkey"). The result is a loop:

1. Passkey is verified on `/passkey` → `sendPasskey` →
`completeAuthFlow` → `loginWithOIDCAndSession`
2. `isSessionValid` returns `false` because the configured TOTP/OTP
factor was never checked in this session
3. The re-authentication branch calls `sendLoginname`, which resolves
the user's preferred method (passkey) and returns a redirect to
`/passkey` — back to step 1, with no way forward

# How the Problems Are Solved

Treat a user-verified passkey authentication as fulfilling the MFA
requirement in `isSessionValid`, consistent with the existing passkey
escape in `checkMFAFactors`. A passkey with user verification is
inherently multi-factor (possession + biometrics/PIN).

- `isSessionValid` now derives `hasAuthenticatedWithPasskey` from
`factors.webAuthN.verifiedAt && factors.webAuthN.userVerified` and
accepts it alongside the configured-factor checks (`totpValid ||
otpEmailValid || otpSmsValid || u2fValid`).
- A presence-only WebAuthn assertion (`userVerified: false`, i.e. a
U2F-style second-factor check) does **not** count, so password-based
sessions still require their configured second factor — the original
security fix remains intact.
- Adds regression tests: a user-verified passkey session with a
configured-but-unverified TOTP factor is valid; a presence-only WebAuthn
session with an unverified TOTP factor remains invalid.
2026-08-11 14:16:45 +02:00
30434d176c feat(login): Sign in with Zitadel (#12530)
Closes #11824

# Which Problems Are Solved

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

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

# How the Problems Are Solved

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

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

# Additional Changes

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

# Additional Context

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

---------

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

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

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

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

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

# How the Problems Are Solved

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

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

# Additional Changes

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

# Additional Context

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

Co-authored-by: Cursor <[email protected]>
2026-08-07 17:23:56 +02:00
MaximilianandGitHub 9fa0cad9d9 docs: Update production guidelines (#12552) 2026-08-07 10:48:21 +02:00
be5b278069 test(login): avoid assert.Never race in forged registration check (#12548)
# Which Problems Are Solved

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

# How the Problems Are Solved

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

# Additional Changes

- None.

# Additional Context

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

Co-authored-by: Cursor <[email protected]>
2026-08-06 07:50:21 +02:00
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>&nbsp;<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>&nbsp;</div>

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Silvan <[email protected]>
2026-08-05 09:28:16 +02:00
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 <[email protected]>
2026-08-04 12:49:28 +00:00
jmaretteandGitHub 9070b537d2 feat(oidc): add RFC 7592 dynamic client management (#12315)
# Which Problems Are Solved

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

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

# How the Problems Are Solved

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

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

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

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

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

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

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

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

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

## The token itself

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

## Reusing the existing persistence

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

# Additional Changes

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

# Additional Context

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

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

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

# How the Problems Are Solved

## Control plane: instance security settings

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

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

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

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

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

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

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

## Registration itself

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

# Additional Changes

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

# Additional Context

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

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

# How the Problems Are Solved

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

# Additional Changes

N/A

# Additional Context

introduced in
https://github.com/zitadel/zitadel/commit/a17af0fe934ff94fcbf6c38d56aee308e1694962#diff-ec5370dd3f47f190c01e8dee4fd027b334a523cf31e5557422dd14c6e6c90339R859
2026-07-29 15:31:34 +02:00
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 <[email protected]>
Co-authored-by: Max Peintner <[email protected]>
2026-07-29 12:06:36 +00:00
a17af0fe93 Merge commit from fork
* fix(login): prevent external-IDP account pre-hijack in Login V1 (GHSA-738m-7888-jfv8)

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

GHSA-45f2-5q3r-xgg6

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

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

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

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

GHSA-45f2-5q3r-xgg6

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

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

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

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

GHSA-45f2-5q3r-xgg6

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

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

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

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

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

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

GHSA-45f2-5q3r-xgg6

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

* check for userverified true as well, cleanup return message

---------

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

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

# How the Problems Are Solved

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

# Additional Changes

N/A

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

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

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

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

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

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

## Approach

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

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

## Test plan

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

<!-- CURSOR_AGENT_PR_BODY_END -->

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

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Silvan <[email protected]>
2026-07-28 11:56:07 +00:00
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 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-27 14:59:02 +00:00