* 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]>
# Which Problems Are Solved
- Upgrading to `zitadel/passwap` v0.12.1 introduced new encoded-hash
validation paths that still had review feedback open.
- Secret hasher defaults were internally inconsistent (`Hasher.Cost: 4`
vs `Limits.Bcrypt.MinCost: 10`), which could reject hashes created by
the configured hasher.
- New validation error IDs/messages and test coverage needed to be
aligned with project conventions and expected behavior branches.
# How the Problems Are Solved
- Kept the dependency upgrade to `zitadel/passwap` v0.12.1 and completed
the validation integration.
- Updated `ValidateEncodedHash` error handling in
`internal/crypto/passwap.go` to:
- use unique random-style error IDs,
- return `Errors.Hash.NotSupported` for no-verifier cases,
- keep invalid-hash branches mapped to invalid argument errors.
- Expanded `TestHasher_ValidateEncodedHash` in
`internal/crypto/passwap_test.go` to cover and assert:
- bounds error branch,
- no-verifier branch,
- generic invalid-hash branch,
- expected ZITADEL error IDs/messages.
- Restored lost inline verifier-context comments for argon2 and md5plain
verifier entries.
# Additional Changes
- Added the missing explanatory `Limits` comment for `SecretHasher` in
`cmd/defaults.yaml`.
- Corrected `SecretHasher.Limits.Bcrypt.MinCost` from `10` to `4` to
match the configured default bcrypt cost and avoid configuration
footguns.
# Additional Context
- Follow-up for PR review feedback in
https://github.com/zitadel/zitadel/pull/12179#pullrequestreview-4313121965
---------
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: Livio Spring <[email protected]>
# Which Problems Are Solved
Opaque tokens now use authenticated encryption.
# How the Problems Are Solved
- Upgrade zitadel/oidc to v3.47
- Copy crypto implementation for refresh and session tokens (internal to
zitadel)
- Added config that allows validating old tokens for gradual roll-out
# Additional Changes
- Set NX cache for `integration-test-build` to `false`, working on a
seperate fix.
# Additional Context
- closes#11315
---------
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: muhlemmer <[email protected]>
# Which Problems Are Solved
Twilio supports a robust, multi-channel verification service that
notably supports multi-region SMS sender numbers required for our use
case. Currently, Zitadel does much of the work of the Twilio Verify (eg.
localization, code generation, messaging) but doesn't support the pool
of sender numbers that Twilio Verify does.
# How the Problems Are Solved
To support this API, we need to be able to store the Twilio Service ID
and send that in a verification request where appropriate: phone number
verification and SMS 2FA code paths.
This PR does the following:
- Adds the ability to use Twilio Verify of standard messaging through
Twilio
- Adds support for international numbers and more reliable verification
messages sent from multiple numbers
- Adds a new Twilio configuration option to support Twilio Verify in the
admin console
- Sends verification SMS messages through Twilio Verify
- Implements Twilio Verification Checks for codes generated through the
same
# Additional Changes
# Additional Context
- base was implemented by @zhirschtritt in
https://github.com/zitadel/zitadel/pull/8268❤️
- closes https://github.com/zitadel/zitadel/issues/8581
---------
Co-authored-by: Zachary Hirschtritt <[email protected]>
Co-authored-by: Joey Biscoglia <[email protected]>
# Which Problems Are Solved
As an administrator I want to be able to invite users to my application
with the API V2, some user data I will already prefil, the user should
add the authentication method themself (password, passkey, sso).
# How the Problems Are Solved
- A user can now be created with a email explicitly set to false.
- If a user has no verified email and no authentication method, an
`InviteCode` can be created through the User V2 API.
- the code can be returned or sent through email
- additionally `URLTemplate` and an `ApplicatioName` can provided for
the email
- The code can be resent and verified through the User V2 API
- The V1 login allows users to verify and resend the code and set a
password (analog user initialization)
- The message text for the user invitation can be customized
# Additional Changes
- `verifyUserPasskeyCode` directly uses `crypto.VerifyCode` (instead of
`verifyEncryptedCode`)
- `verifyEncryptedCode` is removed (unnecessarily queried for the code
generator)
# Additional Context
- closes#8310
- TODO: login V2 will have to implement invite flow:
https://github.com/zitadel/typescript/issues/166
This change adds a core_generate_all make target.
It installs the required tools and runs generate on the complete project.
`golang/mock` is no longer maintained and a fork is available
from the Uber folks. So the latter is used as tool.
All the mock files have been regenerated and are part of the PR.
The obsolete `tools` directory has been removed,
as all the tools are now part of specific make targets.
Co-authored-by: Silvan <[email protected]>
* feat: add otp (sms and email) checks in session api
* implement sending
* fix tests
* add tests
* add integration tests
* fix merge main and add tests
* put default OTP Email url into config
---------
Co-authored-by: Stefan Benz <[email protected]>
* command/crypto: DRY the code
- reuse the the algorithm switch to create a secret generator
- add a verifyCryptoCode function
* command: crypto code tests
* migrate webauthn package
* finish integration tests with webauthn mock client