diff --git a/cmd/initialise/verify_database.go b/cmd/initialise/verify_database.go
index ac7d39253a..be3ec19bab 100644
--- a/cmd/initialise/verify_database.go
+++ b/cmd/initialise/verify_database.go
@@ -38,6 +38,6 @@ func VerifyDatabase(databaseName string) func(*database.DB) error {
return func(db *database.DB) error {
logging.WithFields("database", databaseName).Info("verify database")
- return exec(db, fmt.Sprintf(string(databaseStmt), databaseName), []string{dbAlreadyExistsCode})
+ return exec(db, fmt.Sprintf(databaseStmt, databaseName), []string{dbAlreadyExistsCode})
}
}
diff --git a/cmd/initialise/verify_zitadel.go b/cmd/initialise/verify_zitadel.go
index 53203eb4f6..7c16fdaadf 100644
--- a/cmd/initialise/verify_zitadel.go
+++ b/cmd/initialise/verify_zitadel.go
@@ -95,7 +95,8 @@ func createEncryptionKeys(ctx context.Context, db *database.DB) error {
return err
}
if _, err = tx.Exec(createEncryptionKeysStmt); err != nil {
- tx.Rollback()
+ rollbackErr := tx.Rollback()
+ logging.OnError(rollbackErr).Error("rollback failed")
return err
}
@@ -110,7 +111,7 @@ func createEvents(ctx context.Context, db *database.DB) (err error) {
defer func() {
if err != nil {
rollbackErr := tx.Rollback()
- logging.OnError(rollbackErr).Debug("rollback failed")
+ logging.OnError(rollbackErr).Error("rollback failed")
return
}
err = tx.Commit()
diff --git a/cmd/key/masterkey.go b/cmd/key/masterkey.go
index e1d3792b8a..9c14eb020e 100644
--- a/cmd/key/masterkey.go
+++ b/cmd/key/masterkey.go
@@ -2,7 +2,6 @@ package key
import (
"errors"
- "io/ioutil"
"os"
"github.com/spf13/cobra"
@@ -42,7 +41,7 @@ func MasterKey(cmd *cobra.Command) (string, error) {
if masterKeyFromEnv {
return os.Getenv(envMasterKey), nil
}
- data, err := ioutil.ReadFile(masterKeyFile)
+ data, err := os.ReadFile(masterKeyFile)
if err != nil {
return "", err
}
diff --git a/cmd/start/start.go b/cmd/start/start.go
index e424a3d903..0969c5388a 100644
--- a/cmd/start/start.go
+++ b/cmd/start/start.go
@@ -37,15 +37,21 @@ import (
action_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/action/v3alpha"
"github.com/zitadel/zitadel/internal/api/grpc/admin"
"github.com/zitadel/zitadel/internal/api/grpc/auth"
- "github.com/zitadel/zitadel/internal/api/grpc/feature/v2"
+ feature_v2 "github.com/zitadel/zitadel/internal/api/grpc/feature/v2"
+ feature_v2beta "github.com/zitadel/zitadel/internal/api/grpc/feature/v2beta"
"github.com/zitadel/zitadel/internal/api/grpc/management"
oidc_v2 "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2"
- "github.com/zitadel/zitadel/internal/api/grpc/org/v2"
- "github.com/zitadel/zitadel/internal/api/grpc/session/v2"
- "github.com/zitadel/zitadel/internal/api/grpc/settings/v2"
+ oidc_v2beta "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2beta"
+ org_v2 "github.com/zitadel/zitadel/internal/api/grpc/org/v2"
+ org_v2beta "github.com/zitadel/zitadel/internal/api/grpc/org/v2beta"
+ session_v2 "github.com/zitadel/zitadel/internal/api/grpc/session/v2"
+ session_v2beta "github.com/zitadel/zitadel/internal/api/grpc/session/v2beta"
+ settings_v2 "github.com/zitadel/zitadel/internal/api/grpc/settings/v2"
+ settings_v2beta "github.com/zitadel/zitadel/internal/api/grpc/settings/v2beta"
"github.com/zitadel/zitadel/internal/api/grpc/system"
user_schema_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/user/schema/v3alpha"
user_v2 "github.com/zitadel/zitadel/internal/api/grpc/user/v2"
+ user_v2beta "github.com/zitadel/zitadel/internal/api/grpc/user/v2beta"
http_util "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/api/idp"
@@ -399,20 +405,34 @@ func startAPIs(
if err := apis.RegisterServer(ctx, auth.CreateServer(commands, queries, authRepo, config.SystemDefaults, keys.User, config.ExternalSecure), tlsConfig); err != nil {
return nil, err
}
+ if err := apis.RegisterService(ctx, user_v2beta.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(config.ExternalSecure), idp.SAMLRootURL(config.ExternalSecure), assets.AssetAPI(config.ExternalSecure), permissionCheck)); err != nil {
+ return nil, err
+ }
if err := apis.RegisterService(ctx, user_v2.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(config.ExternalSecure), idp.SAMLRootURL(config.ExternalSecure), assets.AssetAPI(config.ExternalSecure), permissionCheck)); err != nil {
return nil, err
}
- if err := apis.RegisterService(ctx, session.CreateServer(commands, queries)); err != nil {
+ if err := apis.RegisterService(ctx, session_v2beta.CreateServer(commands, queries)); err != nil {
return nil, err
}
-
- if err := apis.RegisterService(ctx, settings.CreateServer(commands, queries, config.ExternalSecure)); err != nil {
+ if err := apis.RegisterService(ctx, settings_v2beta.CreateServer(commands, queries, config.ExternalSecure)); err != nil {
return nil, err
}
- if err := apis.RegisterService(ctx, org.CreateServer(commands, queries, permissionCheck)); err != nil {
+ if err := apis.RegisterService(ctx, org_v2beta.CreateServer(commands, queries, permissionCheck)); err != nil {
return nil, err
}
- if err := apis.RegisterService(ctx, feature.CreateServer(commands, queries)); err != nil {
+ if err := apis.RegisterService(ctx, feature_v2beta.CreateServer(commands, queries)); err != nil {
+ return nil, err
+ }
+ if err := apis.RegisterService(ctx, session_v2.CreateServer(commands, queries)); err != nil {
+ return nil, err
+ }
+ if err := apis.RegisterService(ctx, settings_v2.CreateServer(commands, queries, config.ExternalSecure)); err != nil {
+ return nil, err
+ }
+ if err := apis.RegisterService(ctx, org_v2.CreateServer(commands, queries, permissionCheck)); err != nil {
+ return nil, err
+ }
+ if err := apis.RegisterService(ctx, feature_v2.CreateServer(commands, queries)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, action_v3_alpha.CreateServer(commands, queries, domain.AllFunctions, apis.ListGrpcMethods, apis.ListGrpcServices)); err != nil {
@@ -491,6 +511,9 @@ func startAPIs(
apis.HandleFunc(login.EndpointDeviceAuth, login.RedirectDeviceAuthToPrefix)
// After OIDC provider so that the callback endpoint can be used
+ if err := apis.RegisterService(ctx, oidc_v2beta.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil {
+ return nil, err
+ }
if err := apis.RegisterService(ctx, oidc_v2.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil {
return nil, err
}
diff --git a/docs/docs/apis/v2.mdx b/docs/docs/apis/v2.mdx
index 2f51dba6e9..5f5f582a4e 100644
--- a/docs/docs/apis/v2.mdx
+++ b/docs/docs/apis/v2.mdx
@@ -1,5 +1,5 @@
---
-title: APIs V2 (General Available)
+title: APIs V2 (Generally Available)
---
import DocCardList from '@theme/DocCardList';
@@ -7,4 +7,7 @@ import DocCardList from '@theme/DocCardList';
APIs V2 organize access by resources (users, settings, etc.), unlike context-specific V1 APIs.
This simplifies finding the right API, especially for multi-organization resources.
+Users created with the V2 API have no initial state anymore, so new users are immediately active.
+Requesting ZITADEL to send a verification email on user creation is still possible.
+
\ No newline at end of file
diff --git a/docs/package.json b/docs/package.json
index edaf6e72ca..c7e4d36796 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -6,7 +6,7 @@
"docusaurus": "docusaurus",
"start": "docusaurus start",
"start:api": "yarn run generate && docusaurus start",
- "build": "yarn run generate && docusaurus build",
+ "build": "yarn run generate && NODE_OPTIONS=--max-old-space-size=6144 docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
diff --git a/internal/api/grpc/action/v3alpha/execution_integration_test.go b/internal/api/grpc/action/v3alpha/execution_integration_test.go
index fcbdb2b576..2a01e40383 100644
--- a/internal/api/grpc/action/v3alpha/execution_integration_test.go
+++ b/internal/api/grpc/action/v3alpha/execution_integration_test.go
@@ -12,7 +12,7 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/integration"
action "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
)
func executionTargetsSingleTarget(id string) []*action.ExecutionTargetType {
@@ -69,7 +69,7 @@ func TestServer_SetExecution_Request(t *testing.T) {
ConditionType: &action.Condition_Request{
Request: &action.RequestExecution{
Condition: &action.RequestExecution_Method{
- Method: "/zitadel.session.v2beta.NotExistingService/List",
+ Method: "/zitadel.session.v2.NotExistingService/List",
},
},
},
@@ -86,7 +86,7 @@ func TestServer_SetExecution_Request(t *testing.T) {
ConditionType: &action.Condition_Request{
Request: &action.RequestExecution{
Condition: &action.RequestExecution_Method{
- Method: "/zitadel.session.v2beta.SessionService/ListSessions",
+ Method: "/zitadel.session.v2.SessionService/ListSessions",
},
},
},
@@ -125,7 +125,7 @@ func TestServer_SetExecution_Request(t *testing.T) {
ConditionType: &action.Condition_Request{
Request: &action.RequestExecution{
Condition: &action.RequestExecution_Service{
- Service: "zitadel.session.v2beta.SessionService",
+ Service: "zitadel.session.v2.SessionService",
},
},
},
@@ -200,7 +200,7 @@ func TestServer_SetExecution_Request_Include(t *testing.T) {
ConditionType: &action.Condition_Request{
Request: &action.RequestExecution{
Condition: &action.RequestExecution_Service{
- Service: "zitadel.session.v2beta.SessionService",
+ Service: "zitadel.session.v2.SessionService",
},
},
},
@@ -213,7 +213,7 @@ func TestServer_SetExecution_Request_Include(t *testing.T) {
ConditionType: &action.Condition_Request{
Request: &action.RequestExecution{
Condition: &action.RequestExecution_Method{
- Method: "/zitadel.session.v2beta.SessionService/ListSessions",
+ Method: "/zitadel.session.v2.SessionService/ListSessions",
},
},
},
@@ -247,7 +247,7 @@ func TestServer_SetExecution_Request_Include(t *testing.T) {
ConditionType: &action.Condition_Request{
Request: &action.RequestExecution{
Condition: &action.RequestExecution_Method{
- Method: "/zitadel.session.v2beta.SessionService/ListSessions",
+ Method: "/zitadel.session.v2.SessionService/ListSessions",
},
},
},
@@ -269,7 +269,7 @@ func TestServer_SetExecution_Request_Include(t *testing.T) {
ConditionType: &action.Condition_Request{
Request: &action.RequestExecution{
Condition: &action.RequestExecution_Service{
- Service: "zitadel.session.v2beta.SessionService",
+ Service: "zitadel.session.v2.SessionService",
},
},
},
@@ -347,7 +347,7 @@ func TestServer_DeleteExecution_Request(t *testing.T) {
ConditionType: &action.Condition_Request{
Request: &action.RequestExecution{
Condition: &action.RequestExecution_Method{
- Method: "/zitadel.session.v2beta.SessionService/NotExisting",
+ Method: "/zitadel.session.v2.SessionService/NotExisting",
},
},
},
@@ -367,7 +367,7 @@ func TestServer_DeleteExecution_Request(t *testing.T) {
ConditionType: &action.Condition_Request{
Request: &action.RequestExecution{
Condition: &action.RequestExecution_Method{
- Method: "/zitadel.session.v2beta.SessionService/GetSession",
+ Method: "/zitadel.session.v2.SessionService/GetSession",
},
},
},
@@ -408,7 +408,7 @@ func TestServer_DeleteExecution_Request(t *testing.T) {
ConditionType: &action.Condition_Request{
Request: &action.RequestExecution{
Condition: &action.RequestExecution_Service{
- Service: "zitadel.user.v2beta.UserService",
+ Service: "zitadel.user.v2.UserService",
},
},
},
@@ -512,7 +512,7 @@ func TestServer_SetExecution_Response(t *testing.T) {
ConditionType: &action.Condition_Response{
Response: &action.ResponseExecution{
Condition: &action.ResponseExecution_Method{
- Method: "/zitadel.session.v2beta.NotExistingService/List",
+ Method: "/zitadel.session.v2.NotExistingService/List",
},
},
},
@@ -529,7 +529,7 @@ func TestServer_SetExecution_Response(t *testing.T) {
ConditionType: &action.Condition_Response{
Response: &action.ResponseExecution{
Condition: &action.ResponseExecution_Method{
- Method: "/zitadel.session.v2beta.SessionService/ListSessions",
+ Method: "/zitadel.session.v2.SessionService/ListSessions",
},
},
},
@@ -568,7 +568,7 @@ func TestServer_SetExecution_Response(t *testing.T) {
ConditionType: &action.Condition_Response{
Response: &action.ResponseExecution{
Condition: &action.ResponseExecution_Service{
- Service: "zitadel.session.v2beta.SessionService",
+ Service: "zitadel.session.v2.SessionService",
},
},
},
@@ -670,7 +670,7 @@ func TestServer_DeleteExecution_Response(t *testing.T) {
ConditionType: &action.Condition_Response{
Response: &action.ResponseExecution{
Condition: &action.ResponseExecution_Method{
- Method: "/zitadel.session.v2beta.SessionService/NotExisting",
+ Method: "/zitadel.session.v2.SessionService/NotExisting",
},
},
},
@@ -690,7 +690,7 @@ func TestServer_DeleteExecution_Response(t *testing.T) {
ConditionType: &action.Condition_Response{
Response: &action.ResponseExecution{
Condition: &action.ResponseExecution_Method{
- Method: "/zitadel.session.v2beta.SessionService/GetSession",
+ Method: "/zitadel.session.v2.SessionService/GetSession",
},
},
},
@@ -731,7 +731,7 @@ func TestServer_DeleteExecution_Response(t *testing.T) {
ConditionType: &action.Condition_Response{
Response: &action.ResponseExecution{
Condition: &action.ResponseExecution_Service{
- Service: "zitadel.user.v2beta.UserService",
+ Service: "zitadel.user.v2.UserService",
},
},
},
diff --git a/internal/api/grpc/action/v3alpha/query_integration_test.go b/internal/api/grpc/action/v3alpha/query_integration_test.go
index b083eda5a4..279109ef78 100644
--- a/internal/api/grpc/action/v3alpha/query_integration_test.go
+++ b/internal/api/grpc/action/v3alpha/query_integration_test.go
@@ -16,7 +16,7 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/integration"
action "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
)
func TestServer_GetTargetByID(t *testing.T) {
@@ -532,7 +532,7 @@ func TestServer_ListExecutions(t *testing.T) {
ConditionType: &action.Condition_Request{
Request: &action.RequestExecution{
Condition: &action.RequestExecution_Method{
- Method: "/zitadel.session.v2beta.SessionService/GetSession",
+ Method: "/zitadel.session.v2.SessionService/GetSession",
},
},
},
@@ -555,7 +555,7 @@ func TestServer_ListExecutions(t *testing.T) {
ConditionType: &action.Condition_Request{
Request: &action.RequestExecution{
Condition: &action.RequestExecution_Method{
- Method: "/zitadel.session.v2beta.SessionService/GetSession",
+ Method: "/zitadel.session.v2.SessionService/GetSession",
},
},
},
@@ -720,7 +720,7 @@ func TestServer_ListExecutions(t *testing.T) {
ConditionType: &action.Condition_Request{
Request: &action.RequestExecution{
Condition: &action.RequestExecution_Method{
- Method: "/zitadel.session.v2beta.SessionService/GetSession",
+ Method: "/zitadel.session.v2.SessionService/GetSession",
},
},
},
@@ -729,7 +729,7 @@ func TestServer_ListExecutions(t *testing.T) {
ConditionType: &action.Condition_Request{
Request: &action.RequestExecution{
Condition: &action.RequestExecution_Method{
- Method: "/zitadel.session.v2beta.SessionService/CreateSession",
+ Method: "/zitadel.session.v2.SessionService/CreateSession",
},
},
},
@@ -738,7 +738,7 @@ func TestServer_ListExecutions(t *testing.T) {
ConditionType: &action.Condition_Request{
Request: &action.RequestExecution{
Condition: &action.RequestExecution_Method{
- Method: "/zitadel.session.v2beta.SessionService/SetSession",
+ Method: "/zitadel.session.v2.SessionService/SetSession",
},
},
},
@@ -795,11 +795,11 @@ func TestServer_ListExecutions(t *testing.T) {
Query: &action.SearchQuery_InConditionsQuery{
InConditionsQuery: &action.InConditionsQuery{
Conditions: []*action.Condition{
- {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Method{Method: "/zitadel.session.v2beta.SessionService/GetSession"}}}},
- {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Service{Service: "zitadel.session.v2beta.SessionService"}}}},
+ {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Method{Method: "/zitadel.session.v2.SessionService/GetSession"}}}},
+ {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Service{Service: "zitadel.session.v2.SessionService"}}}},
{ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_All{All: true}}}},
- {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Method{Method: "/zitadel.session.v2beta.SessionService/GetSession"}}}},
- {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Service{Service: "zitadel.session.v2beta.SessionService"}}}},
+ {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Method{Method: "/zitadel.session.v2.SessionService/GetSession"}}}},
+ {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Service{Service: "zitadel.session.v2.SessionService"}}}},
{ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_All{All: true}}}},
{ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_Event{Event: "user.added"}}}},
{ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_Group{Group: "user"}}}},
diff --git a/internal/api/grpc/action/v3alpha/server_integration_test.go b/internal/api/grpc/action/v3alpha/server_integration_test.go
index d27105fc48..e97605e1f0 100644
--- a/internal/api/grpc/action/v3alpha/server_integration_test.go
+++ b/internal/api/grpc/action/v3alpha/server_integration_test.go
@@ -14,7 +14,7 @@ import (
"github.com/zitadel/zitadel/internal/integration"
action "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha"
- feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/feature/v2"
)
var (
diff --git a/internal/api/grpc/action/v3alpha/target_integration_test.go b/internal/api/grpc/action/v3alpha/target_integration_test.go
index 8b143fddb8..539f3c6d35 100644
--- a/internal/api/grpc/action/v3alpha/target_integration_test.go
+++ b/internal/api/grpc/action/v3alpha/target_integration_test.go
@@ -17,7 +17,7 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/integration"
action "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
)
func TestServer_CreateTarget(t *testing.T) {
diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go
index e65a1f26b1..4d0698feaf 100644
--- a/internal/api/grpc/feature/v2/converter.go
+++ b/internal/api/grpc/feature/v2/converter.go
@@ -5,7 +5,7 @@ import (
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/feature"
"github.com/zitadel/zitadel/internal/query"
- feature_pb "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
+ feature_pb "github.com/zitadel/zitadel/pkg/grpc/feature/v2"
)
func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command.SystemFeatures {
diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go
index 35dbf98014..7c2cf5fc39 100644
--- a/internal/api/grpc/feature/v2/converter_test.go
+++ b/internal/api/grpc/feature/v2/converter_test.go
@@ -12,8 +12,8 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/feature"
"github.com/zitadel/zitadel/internal/query"
- feature_pb "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ feature_pb "github.com/zitadel/zitadel/pkg/grpc/feature/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
)
func Test_systemFeaturesToCommand(t *testing.T) {
diff --git a/internal/api/grpc/feature/v2/feature.go b/internal/api/grpc/feature/v2/feature.go
index 1e4200ccf1..9125dea518 100644
--- a/internal/api/grpc/feature/v2/feature.go
+++ b/internal/api/grpc/feature/v2/feature.go
@@ -7,7 +7,7 @@ import (
"google.golang.org/grpc/status"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
- feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/feature/v2"
)
func (s *Server) SetSystemFeatures(ctx context.Context, req *feature.SetSystemFeaturesRequest) (_ *feature.SetSystemFeaturesResponse, err error) {
diff --git a/internal/api/grpc/feature/v2/feature_integration_test.go b/internal/api/grpc/feature/v2/feature_integration_test.go
index 5dcd0c37f4..3936b4bcd5 100644
--- a/internal/api/grpc/feature/v2/feature_integration_test.go
+++ b/internal/api/grpc/feature/v2/feature_integration_test.go
@@ -14,8 +14,8 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/integration"
- feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/feature/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
)
var (
diff --git a/internal/api/grpc/feature/v2/server.go b/internal/api/grpc/feature/v2/server.go
index 4208c4acfc..ab92df5822 100644
--- a/internal/api/grpc/feature/v2/server.go
+++ b/internal/api/grpc/feature/v2/server.go
@@ -7,7 +7,7 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc/server"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/query"
- feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/feature/v2"
)
type Server struct {
diff --git a/internal/api/grpc/feature/v2beta/converter.go b/internal/api/grpc/feature/v2beta/converter.go
new file mode 100644
index 0000000000..c866cc017d
--- /dev/null
+++ b/internal/api/grpc/feature/v2beta/converter.go
@@ -0,0 +1,155 @@
+package feature
+
+import (
+ object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/feature"
+ "github.com/zitadel/zitadel/internal/query"
+ feature_pb "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
+)
+
+func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command.SystemFeatures {
+ return &command.SystemFeatures{
+ LoginDefaultOrg: req.LoginDefaultOrg,
+ TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections,
+ LegacyIntrospection: req.OidcLegacyIntrospection,
+ UserSchema: req.UserSchema,
+ Actions: req.Actions,
+ TokenExchange: req.OidcTokenExchange,
+ ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance),
+ }
+}
+
+func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesResponse {
+ return &feature_pb.GetSystemFeaturesResponse{
+ Details: object.DomainToDetailsPb(f.Details),
+ LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg),
+ OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections),
+ OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection),
+ UserSchema: featureSourceToFlagPb(&f.UserSchema),
+ OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange),
+ Actions: featureSourceToFlagPb(&f.Actions),
+ ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance),
+ }
+}
+
+func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *command.InstanceFeatures {
+ return &command.InstanceFeatures{
+ LoginDefaultOrg: req.LoginDefaultOrg,
+ TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections,
+ LegacyIntrospection: req.OidcLegacyIntrospection,
+ UserSchema: req.UserSchema,
+ TokenExchange: req.OidcTokenExchange,
+ Actions: req.Actions,
+ ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance),
+ }
+}
+
+func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeaturesResponse {
+ return &feature_pb.GetInstanceFeaturesResponse{
+ Details: object.DomainToDetailsPb(f.Details),
+ LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg),
+ OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections),
+ OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection),
+ UserSchema: featureSourceToFlagPb(&f.UserSchema),
+ OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange),
+ Actions: featureSourceToFlagPb(&f.Actions),
+ ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance),
+ }
+}
+
+func featureSourceToImprovedPerformanceFlagPb(fs *query.FeatureSource[[]feature.ImprovedPerformanceType]) *feature_pb.ImprovedPerformanceFeatureFlag {
+ return &feature_pb.ImprovedPerformanceFeatureFlag{
+ ExecutionPaths: improvedPerformanceTypesToPb(fs.Value),
+ Source: featureLevelToSourcePb(fs.Level),
+ }
+}
+
+func featureSourceToFlagPb(fs *query.FeatureSource[bool]) *feature_pb.FeatureFlag {
+ return &feature_pb.FeatureFlag{
+ Enabled: fs.Value,
+ Source: featureLevelToSourcePb(fs.Level),
+ }
+}
+
+func featureLevelToSourcePb(level feature.Level) feature_pb.Source {
+ switch level {
+ case feature.LevelUnspecified:
+ return feature_pb.Source_SOURCE_UNSPECIFIED
+ case feature.LevelSystem:
+ return feature_pb.Source_SOURCE_SYSTEM
+ case feature.LevelInstance:
+ return feature_pb.Source_SOURCE_INSTANCE
+ case feature.LevelOrg:
+ return feature_pb.Source_SOURCE_ORGANIZATION
+ case feature.LevelProject:
+ return feature_pb.Source_SOURCE_PROJECT
+ case feature.LevelApp:
+ return feature_pb.Source_SOURCE_APP
+ case feature.LevelUser:
+ return feature_pb.Source_SOURCE_USER
+ default:
+ return feature_pb.Source(level)
+ }
+}
+
+func improvedPerformanceTypesToPb(types []feature.ImprovedPerformanceType) []feature_pb.ImprovedPerformance {
+ res := make([]feature_pb.ImprovedPerformance, len(types))
+
+ for i, typ := range types {
+ res[i] = improvedPerformanceTypeToPb(typ)
+ }
+
+ return res
+}
+
+func improvedPerformanceTypeToPb(typ feature.ImprovedPerformanceType) feature_pb.ImprovedPerformance {
+ switch typ {
+ case feature.ImprovedPerformanceTypeUnknown:
+ return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_UNSPECIFIED
+ case feature.ImprovedPerformanceTypeOrgByID:
+ return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID
+ case feature.ImprovedPerformanceTypeProjectGrant:
+ return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_PROJECT_GRANT
+ case feature.ImprovedPerformanceTypeProject:
+ return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_PROJECT
+ case feature.ImprovedPerformanceTypeUserGrant:
+ return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_USER_GRANT
+ case feature.ImprovedPerformanceTypeOrgDomainVerified:
+ return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_DOMAIN_VERIFIED
+ default:
+ return feature_pb.ImprovedPerformance(typ)
+ }
+}
+
+func improvedPerformanceListToDomain(list []feature_pb.ImprovedPerformance) []feature.ImprovedPerformanceType {
+ if list == nil {
+ return nil
+ }
+ res := make([]feature.ImprovedPerformanceType, len(list))
+
+ for i, typ := range list {
+ res[i] = improvedPerformanceToDomain(typ)
+ }
+
+ return res
+}
+
+func improvedPerformanceToDomain(typ feature_pb.ImprovedPerformance) feature.ImprovedPerformanceType {
+ switch typ {
+ case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_UNSPECIFIED:
+ return feature.ImprovedPerformanceTypeUnknown
+ case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID:
+ return feature.ImprovedPerformanceTypeOrgByID
+ case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_PROJECT_GRANT:
+ return feature.ImprovedPerformanceTypeProjectGrant
+ case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_PROJECT:
+ return feature.ImprovedPerformanceTypeProject
+ case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_USER_GRANT:
+ return feature.ImprovedPerformanceTypeUserGrant
+ case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_DOMAIN_VERIFIED:
+ return feature.ImprovedPerformanceTypeOrgDomainVerified
+ default:
+ return feature.ImprovedPerformanceTypeUnknown
+ }
+}
diff --git a/internal/api/grpc/feature/v2beta/converter_test.go b/internal/api/grpc/feature/v2beta/converter_test.go
new file mode 100644
index 0000000000..35dbf98014
--- /dev/null
+++ b/internal/api/grpc/feature/v2beta/converter_test.go
@@ -0,0 +1,268 @@
+package feature
+
+import (
+ "testing"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/feature"
+ "github.com/zitadel/zitadel/internal/query"
+ feature_pb "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+)
+
+func Test_systemFeaturesToCommand(t *testing.T) {
+ arg := &feature_pb.SetSystemFeaturesRequest{
+ LoginDefaultOrg: gu.Ptr(true),
+ OidcTriggerIntrospectionProjections: gu.Ptr(false),
+ OidcLegacyIntrospection: nil,
+ UserSchema: gu.Ptr(true),
+ Actions: gu.Ptr(true),
+ OidcTokenExchange: gu.Ptr(true),
+ ImprovedPerformance: nil,
+ }
+ want := &command.SystemFeatures{
+ LoginDefaultOrg: gu.Ptr(true),
+ TriggerIntrospectionProjections: gu.Ptr(false),
+ LegacyIntrospection: nil,
+ UserSchema: gu.Ptr(true),
+ Actions: gu.Ptr(true),
+ TokenExchange: gu.Ptr(true),
+ ImprovedPerformance: nil,
+ }
+ got := systemFeaturesToCommand(arg)
+ assert.Equal(t, want, got)
+}
+
+func Test_systemFeaturesToPb(t *testing.T) {
+ arg := &query.SystemFeatures{
+ Details: &domain.ObjectDetails{
+ Sequence: 22,
+ EventDate: time.Unix(123, 0),
+ ResourceOwner: "SYSTEM",
+ },
+ LoginDefaultOrg: query.FeatureSource[bool]{
+ Level: feature.LevelSystem,
+ Value: true,
+ },
+ TriggerIntrospectionProjections: query.FeatureSource[bool]{
+ Level: feature.LevelUnspecified,
+ Value: false,
+ },
+ LegacyIntrospection: query.FeatureSource[bool]{
+ Level: feature.LevelSystem,
+ Value: true,
+ },
+ UserSchema: query.FeatureSource[bool]{
+ Level: feature.LevelSystem,
+ Value: true,
+ },
+ Actions: query.FeatureSource[bool]{
+ Level: feature.LevelSystem,
+ Value: true,
+ },
+ TokenExchange: query.FeatureSource[bool]{
+ Level: feature.LevelSystem,
+ Value: false,
+ },
+ ImprovedPerformance: query.FeatureSource[[]feature.ImprovedPerformanceType]{
+ Level: feature.LevelSystem,
+ Value: []feature.ImprovedPerformanceType{feature.ImprovedPerformanceTypeOrgByID},
+ },
+ }
+ want := &feature_pb.GetSystemFeaturesResponse{
+ Details: &object.Details{
+ Sequence: 22,
+ ChangeDate: ×tamppb.Timestamp{Seconds: 123},
+ ResourceOwner: "SYSTEM",
+ },
+ LoginDefaultOrg: &feature_pb.FeatureFlag{
+ Enabled: true,
+ Source: feature_pb.Source_SOURCE_SYSTEM,
+ },
+ OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{
+ Enabled: false,
+ Source: feature_pb.Source_SOURCE_UNSPECIFIED,
+ },
+ OidcLegacyIntrospection: &feature_pb.FeatureFlag{
+ Enabled: true,
+ Source: feature_pb.Source_SOURCE_SYSTEM,
+ },
+ UserSchema: &feature_pb.FeatureFlag{
+ Enabled: true,
+ Source: feature_pb.Source_SOURCE_SYSTEM,
+ },
+ OidcTokenExchange: &feature_pb.FeatureFlag{
+ Enabled: false,
+ Source: feature_pb.Source_SOURCE_SYSTEM,
+ },
+ Actions: &feature_pb.FeatureFlag{
+ Enabled: true,
+ Source: feature_pb.Source_SOURCE_SYSTEM,
+ },
+ ImprovedPerformance: &feature_pb.ImprovedPerformanceFeatureFlag{
+ ExecutionPaths: []feature_pb.ImprovedPerformance{feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID},
+ Source: feature_pb.Source_SOURCE_SYSTEM,
+ },
+ }
+ got := systemFeaturesToPb(arg)
+ assert.Equal(t, want, got)
+}
+
+func Test_instanceFeaturesToCommand(t *testing.T) {
+ arg := &feature_pb.SetInstanceFeaturesRequest{
+ LoginDefaultOrg: gu.Ptr(true),
+ OidcTriggerIntrospectionProjections: gu.Ptr(false),
+ OidcLegacyIntrospection: nil,
+ UserSchema: gu.Ptr(true),
+ OidcTokenExchange: gu.Ptr(true),
+ Actions: gu.Ptr(true),
+ ImprovedPerformance: nil,
+ }
+ want := &command.InstanceFeatures{
+ LoginDefaultOrg: gu.Ptr(true),
+ TriggerIntrospectionProjections: gu.Ptr(false),
+ LegacyIntrospection: nil,
+ UserSchema: gu.Ptr(true),
+ TokenExchange: gu.Ptr(true),
+ Actions: gu.Ptr(true),
+ ImprovedPerformance: nil,
+ }
+ got := instanceFeaturesToCommand(arg)
+ assert.Equal(t, want, got)
+}
+
+func Test_instanceFeaturesToPb(t *testing.T) {
+ arg := &query.InstanceFeatures{
+ Details: &domain.ObjectDetails{
+ Sequence: 22,
+ EventDate: time.Unix(123, 0),
+ ResourceOwner: "instance1",
+ },
+ LoginDefaultOrg: query.FeatureSource[bool]{
+ Level: feature.LevelSystem,
+ Value: true,
+ },
+ TriggerIntrospectionProjections: query.FeatureSource[bool]{
+ Level: feature.LevelUnspecified,
+ Value: false,
+ },
+ LegacyIntrospection: query.FeatureSource[bool]{
+ Level: feature.LevelInstance,
+ Value: true,
+ },
+ UserSchema: query.FeatureSource[bool]{
+ Level: feature.LevelInstance,
+ Value: true,
+ },
+ Actions: query.FeatureSource[bool]{
+ Level: feature.LevelInstance,
+ Value: true,
+ },
+ TokenExchange: query.FeatureSource[bool]{
+ Level: feature.LevelSystem,
+ Value: false,
+ },
+ ImprovedPerformance: query.FeatureSource[[]feature.ImprovedPerformanceType]{
+ Level: feature.LevelSystem,
+ Value: []feature.ImprovedPerformanceType{feature.ImprovedPerformanceTypeOrgByID},
+ },
+ }
+ want := &feature_pb.GetInstanceFeaturesResponse{
+ Details: &object.Details{
+ Sequence: 22,
+ ChangeDate: ×tamppb.Timestamp{Seconds: 123},
+ ResourceOwner: "instance1",
+ },
+ LoginDefaultOrg: &feature_pb.FeatureFlag{
+ Enabled: true,
+ Source: feature_pb.Source_SOURCE_SYSTEM,
+ },
+ OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{
+ Enabled: false,
+ Source: feature_pb.Source_SOURCE_UNSPECIFIED,
+ },
+ OidcLegacyIntrospection: &feature_pb.FeatureFlag{
+ Enabled: true,
+ Source: feature_pb.Source_SOURCE_INSTANCE,
+ },
+ UserSchema: &feature_pb.FeatureFlag{
+ Enabled: true,
+ Source: feature_pb.Source_SOURCE_INSTANCE,
+ },
+ Actions: &feature_pb.FeatureFlag{
+ Enabled: true,
+ Source: feature_pb.Source_SOURCE_INSTANCE,
+ },
+ OidcTokenExchange: &feature_pb.FeatureFlag{
+ Enabled: false,
+ Source: feature_pb.Source_SOURCE_SYSTEM,
+ },
+ ImprovedPerformance: &feature_pb.ImprovedPerformanceFeatureFlag{
+ ExecutionPaths: []feature_pb.ImprovedPerformance{feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID},
+ Source: feature_pb.Source_SOURCE_SYSTEM,
+ },
+ }
+ got := instanceFeaturesToPb(arg)
+ assert.Equal(t, want, got)
+}
+
+func Test_featureLevelToSourcePb(t *testing.T) {
+ tests := []struct {
+ name string
+ level feature.Level
+ want feature_pb.Source
+ }{
+ {
+ name: "unspecified",
+ level: feature.LevelUnspecified,
+ want: feature_pb.Source_SOURCE_UNSPECIFIED,
+ },
+ {
+ name: "system",
+ level: feature.LevelSystem,
+ want: feature_pb.Source_SOURCE_SYSTEM,
+ },
+ {
+ name: "instance",
+ level: feature.LevelInstance,
+ want: feature_pb.Source_SOURCE_INSTANCE,
+ },
+ {
+ name: "org",
+ level: feature.LevelOrg,
+ want: feature_pb.Source_SOURCE_ORGANIZATION,
+ },
+ {
+ name: "project",
+ level: feature.LevelProject,
+ want: feature_pb.Source_SOURCE_PROJECT,
+ },
+ {
+ name: "app",
+ level: feature.LevelApp,
+ want: feature_pb.Source_SOURCE_APP,
+ },
+ {
+ name: "user",
+ level: feature.LevelUser,
+ want: feature_pb.Source_SOURCE_USER,
+ },
+ {
+ name: "unknown",
+ level: 99,
+ want: 99,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := featureLevelToSourcePb(tt.level)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/internal/api/grpc/feature/v2beta/feature.go b/internal/api/grpc/feature/v2beta/feature.go
new file mode 100644
index 0000000000..b94f8e7de2
--- /dev/null
+++ b/internal/api/grpc/feature/v2beta/feature.go
@@ -0,0 +1,86 @@
+package feature
+
+import (
+ "context"
+
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+
+ object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta"
+ feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
+)
+
+func (s *Server) SetSystemFeatures(ctx context.Context, req *feature.SetSystemFeaturesRequest) (_ *feature.SetSystemFeaturesResponse, err error) {
+ details, err := s.command.SetSystemFeatures(ctx, systemFeaturesToCommand(req))
+ if err != nil {
+ return nil, err
+ }
+ return &feature.SetSystemFeaturesResponse{
+ Details: object.DomainToDetailsPb(details),
+ }, nil
+}
+
+func (s *Server) ResetSystemFeatures(ctx context.Context, req *feature.ResetSystemFeaturesRequest) (_ *feature.ResetSystemFeaturesResponse, err error) {
+ details, err := s.command.ResetSystemFeatures(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return &feature.ResetSystemFeaturesResponse{
+ Details: object.DomainToDetailsPb(details),
+ }, nil
+}
+
+func (s *Server) GetSystemFeatures(ctx context.Context, req *feature.GetSystemFeaturesRequest) (_ *feature.GetSystemFeaturesResponse, err error) {
+ f, err := s.query.GetSystemFeatures(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return systemFeaturesToPb(f), nil
+}
+
+func (s *Server) SetInstanceFeatures(ctx context.Context, req *feature.SetInstanceFeaturesRequest) (_ *feature.SetInstanceFeaturesResponse, err error) {
+ details, err := s.command.SetInstanceFeatures(ctx, instanceFeaturesToCommand(req))
+ if err != nil {
+ return nil, err
+ }
+ return &feature.SetInstanceFeaturesResponse{
+ Details: object.DomainToDetailsPb(details),
+ }, nil
+}
+
+func (s *Server) ResetInstanceFeatures(ctx context.Context, req *feature.ResetInstanceFeaturesRequest) (_ *feature.ResetInstanceFeaturesResponse, err error) {
+ details, err := s.command.ResetInstanceFeatures(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return &feature.ResetInstanceFeaturesResponse{
+ Details: object.DomainToDetailsPb(details),
+ }, nil
+}
+
+func (s *Server) GetInstanceFeatures(ctx context.Context, req *feature.GetInstanceFeaturesRequest) (_ *feature.GetInstanceFeaturesResponse, err error) {
+ f, err := s.query.GetInstanceFeatures(ctx, req.GetInheritance())
+ if err != nil {
+ return nil, err
+ }
+ return instanceFeaturesToPb(f), nil
+}
+
+func (s *Server) SetOrganizationFeatures(ctx context.Context, req *feature.SetOrganizationFeaturesRequest) (_ *feature.SetOrganizationFeaturesResponse, err error) {
+ return nil, status.Errorf(codes.Unimplemented, "method SetOrganizationFeatures not implemented")
+}
+func (s *Server) ResetOrganizationFeatures(ctx context.Context, req *feature.ResetOrganizationFeaturesRequest) (_ *feature.ResetOrganizationFeaturesResponse, err error) {
+ return nil, status.Errorf(codes.Unimplemented, "method ResetOrganizationFeatures not implemented")
+}
+func (s *Server) GetOrganizationFeatures(ctx context.Context, req *feature.GetOrganizationFeaturesRequest) (_ *feature.GetOrganizationFeaturesResponse, err error) {
+ return nil, status.Errorf(codes.Unimplemented, "method GetOrganizationFeatures not implemented")
+}
+func (s *Server) SetUserFeatures(ctx context.Context, req *feature.SetUserFeatureRequest) (_ *feature.SetUserFeaturesResponse, err error) {
+ return nil, status.Errorf(codes.Unimplemented, "method SetUserFeatures not implemented")
+}
+func (s *Server) ResetUserFeatures(ctx context.Context, req *feature.ResetUserFeaturesRequest) (_ *feature.ResetUserFeaturesResponse, err error) {
+ return nil, status.Errorf(codes.Unimplemented, "method ResetUserFeatures not implemented")
+}
+func (s *Server) GetUserFeatures(ctx context.Context, req *feature.GetUserFeaturesRequest) (_ *feature.GetUserFeaturesResponse, err error) {
+ return nil, status.Errorf(codes.Unimplemented, "method GetUserFeatures not implemented")
+}
diff --git a/internal/api/grpc/feature/v2beta/feature_integration_test.go b/internal/api/grpc/feature/v2beta/feature_integration_test.go
new file mode 100644
index 0000000000..794a080202
--- /dev/null
+++ b/internal/api/grpc/feature/v2beta/feature_integration_test.go
@@ -0,0 +1,499 @@
+//go:build integration
+
+package feature_test
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/integration"
+ feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+)
+
+var (
+ SystemCTX context.Context
+ IamCTX context.Context
+ OrgCTX context.Context
+ Tester *integration.Tester
+ Client feature.FeatureServiceClient
+)
+
+func TestMain(m *testing.M) {
+ os.Exit(func() int {
+ ctx, _, cancel := integration.Contexts(5 * time.Minute)
+ defer cancel()
+ Tester = integration.NewTester(ctx)
+ SystemCTX = Tester.WithAuthorization(ctx, integration.SystemUser)
+ IamCTX = Tester.WithAuthorization(ctx, integration.IAMOwner)
+ OrgCTX = Tester.WithAuthorization(ctx, integration.OrgOwner)
+
+ defer Tester.Done()
+ Client = Tester.Client.FeatureV2beta
+
+ return m.Run()
+ }())
+}
+
+func TestServer_SetSystemFeatures(t *testing.T) {
+ type args struct {
+ ctx context.Context
+ req *feature.SetSystemFeaturesRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *feature.SetSystemFeaturesResponse
+ wantErr bool
+ }{
+ {
+ name: "permission error",
+ args: args{
+ ctx: IamCTX,
+ req: &feature.SetSystemFeaturesRequest{
+ OidcTriggerIntrospectionProjections: gu.Ptr(true),
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "no changes error",
+ args: args{
+ ctx: SystemCTX,
+ req: &feature.SetSystemFeaturesRequest{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "success",
+ args: args{
+ ctx: SystemCTX,
+ req: &feature.SetSystemFeaturesRequest{
+ OidcTriggerIntrospectionProjections: gu.Ptr(true),
+ },
+ },
+ want: &feature.SetSystemFeaturesResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: "SYSTEM",
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Cleanup(func() {
+ // make sure we have a clean state after each test
+ _, err := Client.ResetSystemFeatures(SystemCTX, &feature.ResetSystemFeaturesRequest{})
+ require.NoError(t, err)
+ })
+ got, err := Client.SetSystemFeatures(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_ResetSystemFeatures(t *testing.T) {
+ _, err := Client.SetSystemFeatures(SystemCTX, &feature.SetSystemFeaturesRequest{
+ LoginDefaultOrg: gu.Ptr(true),
+ })
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ ctx context.Context
+ want *feature.ResetSystemFeaturesResponse
+ wantErr bool
+ }{
+ {
+ name: "permission error",
+ ctx: IamCTX,
+ wantErr: true,
+ },
+ {
+ name: "success",
+ ctx: SystemCTX,
+ want: &feature.ResetSystemFeaturesResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: "SYSTEM",
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.ResetSystemFeatures(tt.ctx, &feature.ResetSystemFeaturesRequest{})
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_GetSystemFeatures(t *testing.T) {
+ type args struct {
+ ctx context.Context
+ req *feature.GetSystemFeaturesRequest
+ }
+ tests := []struct {
+ name string
+ prepare func(t *testing.T)
+ args args
+ want *feature.GetSystemFeaturesResponse
+ wantErr bool
+ }{
+ {
+ name: "permission error",
+ args: args{
+ ctx: IamCTX,
+ req: &feature.GetSystemFeaturesRequest{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "nothing set",
+ args: args{
+ ctx: SystemCTX,
+ req: &feature.GetSystemFeaturesRequest{},
+ },
+ want: &feature.GetSystemFeaturesResponse{},
+ },
+ {
+ name: "some features",
+ prepare: func(t *testing.T) {
+ _, err := Client.SetSystemFeatures(SystemCTX, &feature.SetSystemFeaturesRequest{
+ LoginDefaultOrg: gu.Ptr(true),
+ OidcTriggerIntrospectionProjections: gu.Ptr(false),
+ })
+ require.NoError(t, err)
+ },
+ args: args{
+ ctx: SystemCTX,
+ req: &feature.GetSystemFeaturesRequest{},
+ },
+ want: &feature.GetSystemFeaturesResponse{
+ LoginDefaultOrg: &feature.FeatureFlag{
+ Enabled: true,
+ Source: feature.Source_SOURCE_SYSTEM,
+ },
+ OidcTriggerIntrospectionProjections: &feature.FeatureFlag{
+ Enabled: false,
+ Source: feature.Source_SOURCE_SYSTEM,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Cleanup(func() {
+ // make sure we have a clean state after each test
+ _, err := Client.ResetSystemFeatures(SystemCTX, &feature.ResetSystemFeaturesRequest{})
+ require.NoError(t, err)
+ })
+ if tt.prepare != nil {
+ tt.prepare(t)
+ }
+ got, err := Client.GetSystemFeatures(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg)
+ assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections)
+ assertFeatureFlag(t, tt.want.OidcLegacyIntrospection, got.OidcLegacyIntrospection)
+ assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema)
+ assertFeatureFlag(t, tt.want.Actions, got.Actions)
+ })
+ }
+}
+
+func TestServer_SetInstanceFeatures(t *testing.T) {
+ type args struct {
+ ctx context.Context
+ req *feature.SetInstanceFeaturesRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *feature.SetInstanceFeaturesResponse
+ wantErr bool
+ }{
+ {
+ name: "permission error",
+ args: args{
+ ctx: OrgCTX,
+ req: &feature.SetInstanceFeaturesRequest{
+ OidcTriggerIntrospectionProjections: gu.Ptr(true),
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "no changes error",
+ args: args{
+ ctx: IamCTX,
+ req: &feature.SetInstanceFeaturesRequest{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "success",
+ args: args{
+ ctx: IamCTX,
+ req: &feature.SetInstanceFeaturesRequest{
+ OidcTriggerIntrospectionProjections: gu.Ptr(true),
+ },
+ },
+ want: &feature.SetInstanceFeaturesResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Cleanup(func() {
+ // make sure we have a clean state after each test
+ _, err := Client.ResetInstanceFeatures(IamCTX, &feature.ResetInstanceFeaturesRequest{})
+ require.NoError(t, err)
+ })
+ got, err := Client.SetInstanceFeatures(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_ResetInstanceFeatures(t *testing.T) {
+ _, err := Client.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{
+ LoginDefaultOrg: gu.Ptr(true),
+ })
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ ctx context.Context
+ want *feature.ResetInstanceFeaturesResponse
+ wantErr bool
+ }{
+ {
+ name: "permission error",
+ ctx: OrgCTX,
+ wantErr: true,
+ },
+ {
+ name: "success",
+ ctx: IamCTX,
+ want: &feature.ResetInstanceFeaturesResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.ResetInstanceFeatures(tt.ctx, &feature.ResetInstanceFeaturesRequest{})
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_GetInstanceFeatures(t *testing.T) {
+ _, err := Client.SetSystemFeatures(SystemCTX, &feature.SetSystemFeaturesRequest{
+ OidcLegacyIntrospection: gu.Ptr(true),
+ })
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ _, err := Client.ResetSystemFeatures(SystemCTX, &feature.ResetSystemFeaturesRequest{})
+ require.NoError(t, err)
+ })
+
+ type args struct {
+ ctx context.Context
+ req *feature.GetInstanceFeaturesRequest
+ }
+ tests := []struct {
+ name string
+ prepare func(t *testing.T)
+ args args
+ want *feature.GetInstanceFeaturesResponse
+ wantErr bool
+ }{
+ {
+ name: "permission error",
+ args: args{
+ ctx: OrgCTX,
+ req: &feature.GetInstanceFeaturesRequest{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "defaults, no inheritance",
+ args: args{
+ ctx: IamCTX,
+ req: &feature.GetInstanceFeaturesRequest{},
+ },
+ want: &feature.GetInstanceFeaturesResponse{},
+ },
+ {
+ name: "defaults, inheritance",
+ args: args{
+ ctx: IamCTX,
+ req: &feature.GetInstanceFeaturesRequest{
+ Inheritance: true,
+ },
+ },
+ want: &feature.GetInstanceFeaturesResponse{
+ LoginDefaultOrg: &feature.FeatureFlag{
+ Enabled: false,
+ Source: feature.Source_SOURCE_UNSPECIFIED,
+ },
+ OidcTriggerIntrospectionProjections: &feature.FeatureFlag{
+ Enabled: false,
+ Source: feature.Source_SOURCE_UNSPECIFIED,
+ },
+ OidcLegacyIntrospection: &feature.FeatureFlag{
+ Enabled: true,
+ Source: feature.Source_SOURCE_SYSTEM,
+ },
+ UserSchema: &feature.FeatureFlag{
+ Enabled: false,
+ Source: feature.Source_SOURCE_UNSPECIFIED,
+ },
+ Actions: &feature.FeatureFlag{
+ Enabled: false,
+ Source: feature.Source_SOURCE_UNSPECIFIED,
+ },
+ },
+ },
+ {
+ name: "some features, no inheritance",
+ prepare: func(t *testing.T) {
+ _, err := Client.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{
+ LoginDefaultOrg: gu.Ptr(true),
+ OidcTriggerIntrospectionProjections: gu.Ptr(false),
+ UserSchema: gu.Ptr(true),
+ Actions: gu.Ptr(true),
+ })
+ require.NoError(t, err)
+ },
+ args: args{
+ ctx: IamCTX,
+ req: &feature.GetInstanceFeaturesRequest{},
+ },
+ want: &feature.GetInstanceFeaturesResponse{
+ LoginDefaultOrg: &feature.FeatureFlag{
+ Enabled: true,
+ Source: feature.Source_SOURCE_INSTANCE,
+ },
+ OidcTriggerIntrospectionProjections: &feature.FeatureFlag{
+ Enabled: false,
+ Source: feature.Source_SOURCE_INSTANCE,
+ },
+ UserSchema: &feature.FeatureFlag{
+ Enabled: true,
+ Source: feature.Source_SOURCE_INSTANCE,
+ },
+ Actions: &feature.FeatureFlag{
+ Enabled: true,
+ Source: feature.Source_SOURCE_INSTANCE,
+ },
+ },
+ },
+ {
+ name: "one feature, inheritance",
+ prepare: func(t *testing.T) {
+ _, err := Client.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{
+ LoginDefaultOrg: gu.Ptr(true),
+ })
+ require.NoError(t, err)
+ },
+ args: args{
+ ctx: IamCTX,
+ req: &feature.GetInstanceFeaturesRequest{
+ Inheritance: true,
+ },
+ },
+ want: &feature.GetInstanceFeaturesResponse{
+ LoginDefaultOrg: &feature.FeatureFlag{
+ Enabled: true,
+ Source: feature.Source_SOURCE_INSTANCE,
+ },
+ OidcTriggerIntrospectionProjections: &feature.FeatureFlag{
+ Enabled: false,
+ Source: feature.Source_SOURCE_UNSPECIFIED,
+ },
+ OidcLegacyIntrospection: &feature.FeatureFlag{
+ Enabled: true,
+ Source: feature.Source_SOURCE_SYSTEM,
+ },
+ UserSchema: &feature.FeatureFlag{
+ Enabled: false,
+ Source: feature.Source_SOURCE_UNSPECIFIED,
+ },
+ Actions: &feature.FeatureFlag{
+ Enabled: false,
+ Source: feature.Source_SOURCE_UNSPECIFIED,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Cleanup(func() {
+ // make sure we have a clean state after each test
+ _, err := Client.ResetInstanceFeatures(IamCTX, &feature.ResetInstanceFeaturesRequest{})
+ require.NoError(t, err)
+ })
+ if tt.prepare != nil {
+ tt.prepare(t)
+ }
+ got, err := Client.GetInstanceFeatures(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg)
+ assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections)
+ assertFeatureFlag(t, tt.want.OidcLegacyIntrospection, got.OidcLegacyIntrospection)
+ assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema)
+ })
+ }
+}
+
+func assertFeatureFlag(t *testing.T, expected, actual *feature.FeatureFlag) {
+ t.Helper()
+ assert.Equal(t, expected.GetEnabled(), actual.GetEnabled(), "enabled")
+ assert.Equal(t, expected.GetSource(), actual.GetSource(), "source")
+}
diff --git a/internal/api/grpc/feature/v2beta/server.go b/internal/api/grpc/feature/v2beta/server.go
new file mode 100644
index 0000000000..4208c4acfc
--- /dev/null
+++ b/internal/api/grpc/feature/v2beta/server.go
@@ -0,0 +1,47 @@
+package feature
+
+import (
+ "google.golang.org/grpc"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
+ "github.com/zitadel/zitadel/internal/api/grpc/server"
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/query"
+ feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
+)
+
+type Server struct {
+ feature.UnimplementedFeatureServiceServer
+ command *command.Commands
+ query *query.Queries
+}
+
+func CreateServer(
+ command *command.Commands,
+ query *query.Queries,
+) *Server {
+ return &Server{
+ command: command,
+ query: query,
+ }
+}
+
+func (s *Server) RegisterServer(grpcServer *grpc.Server) {
+ feature.RegisterFeatureServiceServer(grpcServer, s)
+}
+
+func (s *Server) AppName() string {
+ return feature.FeatureService_ServiceDesc.ServiceName
+}
+
+func (s *Server) MethodPrefix() string {
+ return feature.FeatureService_ServiceDesc.ServiceName
+}
+
+func (s *Server) AuthMethods() authz.MethodMapping {
+ return feature.FeatureService_AuthMethods
+}
+
+func (s *Server) RegisterGateway() server.RegisterGatewayFunc {
+ return feature.RegisterFeatureServiceHandler
+}
diff --git a/internal/api/grpc/object/v2/converter.go b/internal/api/grpc/object/v2/converter.go
index cc7e02c7fe..fe8aba5d6e 100644
--- a/internal/api/grpc/object/v2/converter.go
+++ b/internal/api/grpc/object/v2/converter.go
@@ -8,7 +8,7 @@ import (
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
)
func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details {
diff --git a/internal/api/grpc/object/v2beta/converter.go b/internal/api/grpc/object/v2beta/converter.go
new file mode 100644
index 0000000000..cc7e02c7fe
--- /dev/null
+++ b/internal/api/grpc/object/v2beta/converter.go
@@ -0,0 +1,72 @@
+package object
+
+import (
+ "context"
+
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/query"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+)
+
+func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details {
+ details := &object.Details{
+ Sequence: objectDetail.Sequence,
+ ResourceOwner: objectDetail.ResourceOwner,
+ }
+ if !objectDetail.EventDate.IsZero() {
+ details.ChangeDate = timestamppb.New(objectDetail.EventDate)
+ }
+ return details
+}
+
+func ToListDetails(response query.SearchResponse) *object.ListDetails {
+ details := &object.ListDetails{
+ TotalResult: response.Count,
+ ProcessedSequence: response.Sequence,
+ Timestamp: timestamppb.New(response.EventCreatedAt),
+ }
+
+ return details
+}
+func ListQueryToQuery(query *object.ListQuery) (offset, limit uint64, asc bool) {
+ if query == nil {
+ return 0, 0, false
+ }
+ return query.Offset, uint64(query.Limit), query.Asc
+}
+
+func ResourceOwnerFromReq(ctx context.Context, req *object.RequestContext) string {
+ if req.GetInstance() {
+ return authz.GetInstance(ctx).InstanceID()
+ }
+ if req.GetOrgId() != "" {
+ return req.GetOrgId()
+ }
+ return authz.GetCtxData(ctx).OrgID
+}
+
+func TextMethodToQuery(method object.TextQueryMethod) query.TextComparison {
+ switch method {
+ case object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS:
+ return query.TextEquals
+ case object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE:
+ return query.TextEqualsIgnoreCase
+ case object.TextQueryMethod_TEXT_QUERY_METHOD_STARTS_WITH:
+ return query.TextStartsWith
+ case object.TextQueryMethod_TEXT_QUERY_METHOD_STARTS_WITH_IGNORE_CASE:
+ return query.TextStartsWithIgnoreCase
+ case object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS:
+ return query.TextContains
+ case object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE:
+ return query.TextContainsIgnoreCase
+ case object.TextQueryMethod_TEXT_QUERY_METHOD_ENDS_WITH:
+ return query.TextEndsWith
+ case object.TextQueryMethod_TEXT_QUERY_METHOD_ENDS_WITH_IGNORE_CASE:
+ return query.TextEndsWithIgnoreCase
+ default:
+ return -1
+ }
+}
diff --git a/internal/api/grpc/oidc/v2/oidc.go b/internal/api/grpc/oidc/v2/oidc.go
index 7a13c7ea99..d84edd1c2f 100644
--- a/internal/api/grpc/oidc/v2/oidc.go
+++ b/internal/api/grpc/oidc/v2/oidc.go
@@ -15,7 +15,7 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
- oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
+ oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
)
func (s *Server) GetAuthRequest(ctx context.Context, req *oidc_pb.GetAuthRequestRequest) (*oidc_pb.GetAuthRequestResponse, error) {
diff --git a/internal/api/grpc/oidc/v2/oidc_integration_test.go b/internal/api/grpc/oidc/v2/oidc_integration_test.go
index 27884e80a5..901e667b34 100644
--- a/internal/api/grpc/oidc/v2/oidc_integration_test.go
+++ b/internal/api/grpc/oidc/v2/oidc_integration_test.go
@@ -16,10 +16,10 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/integration"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
- session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/session/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
var (
diff --git a/internal/api/grpc/oidc/v2/oidc_test.go b/internal/api/grpc/oidc/v2/oidc_test.go
index 27dcdf7fb7..c7d06c4a61 100644
--- a/internal/api/grpc/oidc/v2/oidc_test.go
+++ b/internal/api/grpc/oidc/v2/oidc_test.go
@@ -12,7 +12,7 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
- oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
+ oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
)
func Test_authRequestToPb(t *testing.T) {
diff --git a/internal/api/grpc/oidc/v2/server.go b/internal/api/grpc/oidc/v2/server.go
index 7595ae927e..28c7134904 100644
--- a/internal/api/grpc/oidc/v2/server.go
+++ b/internal/api/grpc/oidc/v2/server.go
@@ -8,7 +8,7 @@ import (
"github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/query"
- oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
+ oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
)
var _ oidc_pb.OIDCServiceServer = (*Server)(nil)
diff --git a/internal/api/grpc/oidc/v2beta/oidc.go b/internal/api/grpc/oidc/v2beta/oidc.go
new file mode 100644
index 0000000000..d504e411f0
--- /dev/null
+++ b/internal/api/grpc/oidc/v2beta/oidc.go
@@ -0,0 +1,204 @@
+package oidc
+
+import (
+ "context"
+
+ "github.com/zitadel/logging"
+ "github.com/zitadel/oidc/v3/pkg/op"
+ "google.golang.org/protobuf/types/known/durationpb"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
+ object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/internal/api/http"
+ "github.com/zitadel/zitadel/internal/api/oidc"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/query"
+ "github.com/zitadel/zitadel/internal/zerrors"
+ oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
+)
+
+func (s *Server) GetAuthRequest(ctx context.Context, req *oidc_pb.GetAuthRequestRequest) (*oidc_pb.GetAuthRequestResponse, error) {
+ authRequest, err := s.query.AuthRequestByID(ctx, true, req.GetAuthRequestId(), true)
+ if err != nil {
+ logging.WithError(err).Error("query authRequest by ID")
+ return nil, err
+ }
+ return &oidc_pb.GetAuthRequestResponse{
+ AuthRequest: authRequestToPb(authRequest),
+ }, nil
+}
+
+func authRequestToPb(a *query.AuthRequest) *oidc_pb.AuthRequest {
+ pba := &oidc_pb.AuthRequest{
+ Id: a.ID,
+ CreationDate: timestamppb.New(a.CreationDate),
+ ClientId: a.ClientID,
+ Scope: a.Scope,
+ RedirectUri: a.RedirectURI,
+ Prompt: promptsToPb(a.Prompt),
+ UiLocales: a.UiLocales,
+ LoginHint: a.LoginHint,
+ HintUserId: a.HintUserID,
+ }
+ if a.MaxAge != nil {
+ pba.MaxAge = durationpb.New(*a.MaxAge)
+ }
+ return pba
+}
+
+func promptsToPb(promps []domain.Prompt) []oidc_pb.Prompt {
+ out := make([]oidc_pb.Prompt, len(promps))
+ for i, p := range promps {
+ out[i] = promptToPb(p)
+ }
+ return out
+}
+
+func promptToPb(p domain.Prompt) oidc_pb.Prompt {
+ switch p {
+ case domain.PromptUnspecified:
+ return oidc_pb.Prompt_PROMPT_UNSPECIFIED
+ case domain.PromptNone:
+ return oidc_pb.Prompt_PROMPT_NONE
+ case domain.PromptLogin:
+ return oidc_pb.Prompt_PROMPT_LOGIN
+ case domain.PromptConsent:
+ return oidc_pb.Prompt_PROMPT_CONSENT
+ case domain.PromptSelectAccount:
+ return oidc_pb.Prompt_PROMPT_SELECT_ACCOUNT
+ case domain.PromptCreate:
+ return oidc_pb.Prompt_PROMPT_CREATE
+ default:
+ return oidc_pb.Prompt_PROMPT_UNSPECIFIED
+ }
+}
+
+func (s *Server) CreateCallback(ctx context.Context, req *oidc_pb.CreateCallbackRequest) (*oidc_pb.CreateCallbackResponse, error) {
+ switch v := req.GetCallbackKind().(type) {
+ case *oidc_pb.CreateCallbackRequest_Error:
+ return s.failAuthRequest(ctx, req.GetAuthRequestId(), v.Error)
+ case *oidc_pb.CreateCallbackRequest_Session:
+ return s.linkSessionToAuthRequest(ctx, req.GetAuthRequestId(), v.Session)
+ default:
+ return nil, zerrors.ThrowUnimplementedf(nil, "OIDCv2-zee7A", "verification oneOf %T in method CreateCallback not implemented", v)
+ }
+}
+
+func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*oidc_pb.CreateCallbackResponse, error) {
+ details, aar, err := s.command.FailAuthRequest(ctx, authRequestID, errorReasonToDomain(ae.GetError()))
+ if err != nil {
+ return nil, err
+ }
+ authReq := &oidc.AuthRequestV2{CurrentAuthRequest: aar}
+ callback, err := oidc.CreateErrorCallbackURL(authReq, errorReasonToOIDC(ae.GetError()), ae.GetErrorDescription(), ae.GetErrorUri(), s.op.Provider())
+ if err != nil {
+ return nil, err
+ }
+ return &oidc_pb.CreateCallbackResponse{
+ Details: object.DomainToDetailsPb(details),
+ CallbackUrl: callback,
+ }, nil
+}
+
+func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*oidc_pb.CreateCallbackResponse, error) {
+ details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true)
+ if err != nil {
+ return nil, err
+ }
+ authReq := &oidc.AuthRequestV2{CurrentAuthRequest: aar}
+ ctx = op.ContextWithIssuer(ctx, http.BuildOrigin(authz.GetInstance(ctx).RequestedHost(), s.externalSecure))
+ var callback string
+ if aar.ResponseType == domain.OIDCResponseTypeCode {
+ callback, err = oidc.CreateCodeCallbackURL(ctx, authReq, s.op.Provider())
+ } else {
+ callback, err = s.op.CreateTokenCallbackURL(ctx, authReq)
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &oidc_pb.CreateCallbackResponse{
+ Details: object.DomainToDetailsPb(details),
+ CallbackUrl: callback,
+ }, nil
+}
+
+func errorReasonToDomain(errorReason oidc_pb.ErrorReason) domain.OIDCErrorReason {
+ switch errorReason {
+ case oidc_pb.ErrorReason_ERROR_REASON_UNSPECIFIED:
+ return domain.OIDCErrorReasonUnspecified
+ case oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST:
+ return domain.OIDCErrorReasonInvalidRequest
+ case oidc_pb.ErrorReason_ERROR_REASON_UNAUTHORIZED_CLIENT:
+ return domain.OIDCErrorReasonUnauthorizedClient
+ case oidc_pb.ErrorReason_ERROR_REASON_ACCESS_DENIED:
+ return domain.OIDCErrorReasonAccessDenied
+ case oidc_pb.ErrorReason_ERROR_REASON_UNSUPPORTED_RESPONSE_TYPE:
+ return domain.OIDCErrorReasonUnsupportedResponseType
+ case oidc_pb.ErrorReason_ERROR_REASON_INVALID_SCOPE:
+ return domain.OIDCErrorReasonInvalidScope
+ case oidc_pb.ErrorReason_ERROR_REASON_SERVER_ERROR:
+ return domain.OIDCErrorReasonServerError
+ case oidc_pb.ErrorReason_ERROR_REASON_TEMPORARY_UNAVAILABLE:
+ return domain.OIDCErrorReasonTemporaryUnavailable
+ case oidc_pb.ErrorReason_ERROR_REASON_INTERACTION_REQUIRED:
+ return domain.OIDCErrorReasonInteractionRequired
+ case oidc_pb.ErrorReason_ERROR_REASON_LOGIN_REQUIRED:
+ return domain.OIDCErrorReasonLoginRequired
+ case oidc_pb.ErrorReason_ERROR_REASON_ACCOUNT_SELECTION_REQUIRED:
+ return domain.OIDCErrorReasonAccountSelectionRequired
+ case oidc_pb.ErrorReason_ERROR_REASON_CONSENT_REQUIRED:
+ return domain.OIDCErrorReasonConsentRequired
+ case oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST_URI:
+ return domain.OIDCErrorReasonInvalidRequestURI
+ case oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST_OBJECT:
+ return domain.OIDCErrorReasonInvalidRequestObject
+ case oidc_pb.ErrorReason_ERROR_REASON_REQUEST_NOT_SUPPORTED:
+ return domain.OIDCErrorReasonRequestNotSupported
+ case oidc_pb.ErrorReason_ERROR_REASON_REQUEST_URI_NOT_SUPPORTED:
+ return domain.OIDCErrorReasonRequestURINotSupported
+ case oidc_pb.ErrorReason_ERROR_REASON_REGISTRATION_NOT_SUPPORTED:
+ return domain.OIDCErrorReasonRegistrationNotSupported
+ default:
+ return domain.OIDCErrorReasonUnspecified
+ }
+}
+
+func errorReasonToOIDC(reason oidc_pb.ErrorReason) string {
+ switch reason {
+ case oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST:
+ return "invalid_request"
+ case oidc_pb.ErrorReason_ERROR_REASON_UNAUTHORIZED_CLIENT:
+ return "unauthorized_client"
+ case oidc_pb.ErrorReason_ERROR_REASON_ACCESS_DENIED:
+ return "access_denied"
+ case oidc_pb.ErrorReason_ERROR_REASON_UNSUPPORTED_RESPONSE_TYPE:
+ return "unsupported_response_type"
+ case oidc_pb.ErrorReason_ERROR_REASON_INVALID_SCOPE:
+ return "invalid_scope"
+ case oidc_pb.ErrorReason_ERROR_REASON_TEMPORARY_UNAVAILABLE:
+ return "temporarily_unavailable"
+ case oidc_pb.ErrorReason_ERROR_REASON_INTERACTION_REQUIRED:
+ return "interaction_required"
+ case oidc_pb.ErrorReason_ERROR_REASON_LOGIN_REQUIRED:
+ return "login_required"
+ case oidc_pb.ErrorReason_ERROR_REASON_ACCOUNT_SELECTION_REQUIRED:
+ return "account_selection_required"
+ case oidc_pb.ErrorReason_ERROR_REASON_CONSENT_REQUIRED:
+ return "consent_required"
+ case oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST_URI:
+ return "invalid_request_uri"
+ case oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST_OBJECT:
+ return "invalid_request_object"
+ case oidc_pb.ErrorReason_ERROR_REASON_REQUEST_NOT_SUPPORTED:
+ return "request_not_supported"
+ case oidc_pb.ErrorReason_ERROR_REASON_REQUEST_URI_NOT_SUPPORTED:
+ return "request_uri_not_supported"
+ case oidc_pb.ErrorReason_ERROR_REASON_REGISTRATION_NOT_SUPPORTED:
+ return "registration_not_supported"
+ case oidc_pb.ErrorReason_ERROR_REASON_UNSPECIFIED, oidc_pb.ErrorReason_ERROR_REASON_SERVER_ERROR:
+ fallthrough
+ default:
+ return "server_error"
+ }
+}
diff --git a/internal/api/grpc/oidc/v2beta/oidc_integration_test.go b/internal/api/grpc/oidc/v2beta/oidc_integration_test.go
new file mode 100644
index 0000000000..574718212a
--- /dev/null
+++ b/internal/api/grpc/oidc/v2beta/oidc_integration_test.go
@@ -0,0 +1,258 @@
+//go:build integration
+
+package oidc_test
+
+import (
+ "context"
+ "net/url"
+ "os"
+ "regexp"
+ "testing"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/integration"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
+ session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+)
+
+var (
+ CTX context.Context
+ Tester *integration.Tester
+ Client oidc_pb.OIDCServiceClient
+ User *user.AddHumanUserResponse
+)
+
+const (
+ redirectURI = "oidcintegrationtest://callback"
+ redirectURIImplicit = "http://localhost:9999/callback"
+ logoutRedirectURI = "oidcintegrationtest://logged-out"
+)
+
+func TestMain(m *testing.M) {
+ os.Exit(func() int {
+ ctx, _, cancel := integration.Contexts(5 * time.Minute)
+ defer cancel()
+
+ Tester = integration.NewTester(ctx)
+ defer Tester.Done()
+ Client = Tester.Client.OIDCv2beta
+
+ CTX = Tester.WithAuthorization(ctx, integration.OrgOwner)
+ User = Tester.CreateHumanUser(CTX)
+ return m.Run()
+ }())
+}
+
+func TestServer_GetAuthRequest(t *testing.T) {
+ project, err := Tester.CreateProject(CTX)
+ require.NoError(t, err)
+ client, err := Tester.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false)
+ require.NoError(t, err)
+ authRequestID, err := Tester.CreateOIDCAuthRequest(CTX, client.GetClientId(), Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, redirectURI)
+ require.NoError(t, err)
+ now := time.Now()
+
+ tests := []struct {
+ name string
+ AuthRequestID string
+ want *oidc_pb.GetAuthRequestResponse
+ wantErr bool
+ }{
+ {
+ name: "Not found",
+ AuthRequestID: "123",
+ wantErr: true,
+ },
+ {
+ name: "success",
+ AuthRequestID: authRequestID,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.GetAuthRequest(CTX, &oidc_pb.GetAuthRequestRequest{
+ AuthRequestId: tt.AuthRequestID,
+ })
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ authRequest := got.GetAuthRequest()
+ assert.NotNil(t, authRequest)
+ assert.Equal(t, authRequestID, authRequest.GetId())
+ assert.WithinRange(t, authRequest.GetCreationDate().AsTime(), now.Add(-time.Second), now.Add(time.Second))
+ assert.Contains(t, authRequest.GetScope(), "openid")
+ })
+ }
+}
+
+func TestServer_CreateCallback(t *testing.T) {
+ project, err := Tester.CreateProject(CTX)
+ require.NoError(t, err)
+ client, err := Tester.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false)
+ require.NoError(t, err)
+ sessionResp, err := Tester.Client.SessionV2beta.CreateSession(CTX, &session.CreateSessionRequest{
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID,
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ req *oidc_pb.CreateCallbackRequest
+ AuthError string
+ want *oidc_pb.CreateCallbackResponse
+ wantURL *url.URL
+ wantErr bool
+ }{
+ {
+ name: "Not found",
+ req: &oidc_pb.CreateCallbackRequest{
+ AuthRequestId: "123",
+ CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
+ Session: &oidc_pb.Session{
+ SessionId: sessionResp.GetSessionId(),
+ SessionToken: sessionResp.GetSessionToken(),
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "session not found",
+ req: &oidc_pb.CreateCallbackRequest{
+ AuthRequestId: func() string {
+ authRequestID, err := Tester.CreateOIDCAuthRequest(CTX, client.GetClientId(), Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, redirectURI)
+ require.NoError(t, err)
+ return authRequestID
+ }(),
+ CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
+ Session: &oidc_pb.Session{
+ SessionId: "foo",
+ SessionToken: "bar",
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "session token invalid",
+ req: &oidc_pb.CreateCallbackRequest{
+ AuthRequestId: func() string {
+ authRequestID, err := Tester.CreateOIDCAuthRequest(CTX, client.GetClientId(), Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, redirectURI)
+ require.NoError(t, err)
+ return authRequestID
+ }(),
+ CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
+ Session: &oidc_pb.Session{
+ SessionId: sessionResp.GetSessionId(),
+ SessionToken: "bar",
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "fail callback",
+ req: &oidc_pb.CreateCallbackRequest{
+ AuthRequestId: func() string {
+ authRequestID, err := Tester.CreateOIDCAuthRequest(CTX, client.GetClientId(), Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, redirectURI)
+ require.NoError(t, err)
+ return authRequestID
+ }(),
+ CallbackKind: &oidc_pb.CreateCallbackRequest_Error{
+ Error: &oidc_pb.AuthorizationError{
+ Error: oidc_pb.ErrorReason_ERROR_REASON_ACCESS_DENIED,
+ ErrorDescription: gu.Ptr("nope"),
+ ErrorUri: gu.Ptr("https://example.com/docs"),
+ },
+ },
+ },
+ want: &oidc_pb.CreateCallbackResponse{
+ CallbackUrl: regexp.QuoteMeta(`oidcintegrationtest://callback?error=access_denied&error_description=nope&error_uri=https%3A%2F%2Fexample.com%2Fdocs&state=state`),
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "code callback",
+ req: &oidc_pb.CreateCallbackRequest{
+ AuthRequestId: func() string {
+ authRequestID, err := Tester.CreateOIDCAuthRequest(CTX, client.GetClientId(), Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, redirectURI)
+ require.NoError(t, err)
+ return authRequestID
+ }(),
+ CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
+ Session: &oidc_pb.Session{
+ SessionId: sessionResp.GetSessionId(),
+ SessionToken: sessionResp.GetSessionToken(),
+ },
+ },
+ },
+ want: &oidc_pb.CreateCallbackResponse{
+ CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`,
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "implicit",
+ req: &oidc_pb.CreateCallbackRequest{
+ AuthRequestId: func() string {
+ client, err := Tester.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit)
+ require.NoError(t, err)
+ authRequestID, err := Tester.CreateOIDCAuthRequestImplicit(CTX, client.GetClientId(), Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, redirectURIImplicit)
+ require.NoError(t, err)
+ return authRequestID
+ }(),
+ CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
+ Session: &oidc_pb.Session{
+ SessionId: sessionResp.GetSessionId(),
+ SessionToken: sessionResp.GetSessionToken(),
+ },
+ },
+ },
+ want: &oidc_pb.CreateCallbackResponse{
+ CallbackUrl: `http:\/\/localhost:9999\/callback#access_token=(.*)&expires_in=(.*)&id_token=(.*)&state=state&token_type=Bearer`,
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ },
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.CreateCallback(CTX, tt.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ integration.AssertDetails(t, tt.want, got)
+ if tt.want != nil {
+ assert.Regexp(t, regexp.MustCompile(tt.want.CallbackUrl), got.GetCallbackUrl())
+ }
+ })
+ }
+}
diff --git a/internal/api/grpc/oidc/v2beta/oidc_test.go b/internal/api/grpc/oidc/v2beta/oidc_test.go
new file mode 100644
index 0000000000..27dcdf7fb7
--- /dev/null
+++ b/internal/api/grpc/oidc/v2beta/oidc_test.go
@@ -0,0 +1,150 @@
+package oidc
+
+import (
+ "testing"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/types/known/durationpb"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/query"
+ oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
+)
+
+func Test_authRequestToPb(t *testing.T) {
+ now := time.Now()
+ arg := &query.AuthRequest{
+ ID: "authID",
+ CreationDate: now,
+ ClientID: "clientID",
+ Scope: []string{"a", "b", "c"},
+ RedirectURI: "callbackURI",
+ Prompt: []domain.Prompt{
+ domain.PromptUnspecified,
+ domain.PromptNone,
+ domain.PromptLogin,
+ domain.PromptConsent,
+ domain.PromptSelectAccount,
+ domain.PromptCreate,
+ 999,
+ },
+ UiLocales: []string{"en", "fi"},
+ LoginHint: gu.Ptr("foo@bar.com"),
+ MaxAge: gu.Ptr(time.Minute),
+ HintUserID: gu.Ptr("userID"),
+ }
+ want := &oidc_pb.AuthRequest{
+ Id: "authID",
+ CreationDate: timestamppb.New(now),
+ ClientId: "clientID",
+ RedirectUri: "callbackURI",
+ Prompt: []oidc_pb.Prompt{
+ oidc_pb.Prompt_PROMPT_UNSPECIFIED,
+ oidc_pb.Prompt_PROMPT_NONE,
+ oidc_pb.Prompt_PROMPT_LOGIN,
+ oidc_pb.Prompt_PROMPT_CONSENT,
+ oidc_pb.Prompt_PROMPT_SELECT_ACCOUNT,
+ oidc_pb.Prompt_PROMPT_CREATE,
+ oidc_pb.Prompt_PROMPT_UNSPECIFIED,
+ },
+ UiLocales: []string{"en", "fi"},
+ Scope: []string{"a", "b", "c"},
+ LoginHint: gu.Ptr("foo@bar.com"),
+ MaxAge: durationpb.New(time.Minute),
+ HintUserId: gu.Ptr("userID"),
+ }
+ got := authRequestToPb(arg)
+ if !proto.Equal(want, got) {
+ t.Errorf("authRequestToPb() =\n%v\nwant\n%v\n", got, want)
+ }
+}
+
+func Test_errorReasonToOIDC(t *testing.T) {
+ tests := []struct {
+ reason oidc_pb.ErrorReason
+ want string
+ }{
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_UNSPECIFIED,
+ want: "server_error",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST,
+ want: "invalid_request",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_UNAUTHORIZED_CLIENT,
+ want: "unauthorized_client",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_ACCESS_DENIED,
+ want: "access_denied",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_UNSUPPORTED_RESPONSE_TYPE,
+ want: "unsupported_response_type",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_INVALID_SCOPE,
+ want: "invalid_scope",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_SERVER_ERROR,
+ want: "server_error",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_TEMPORARY_UNAVAILABLE,
+ want: "temporarily_unavailable",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_INTERACTION_REQUIRED,
+ want: "interaction_required",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_LOGIN_REQUIRED,
+ want: "login_required",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_ACCOUNT_SELECTION_REQUIRED,
+ want: "account_selection_required",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_CONSENT_REQUIRED,
+ want: "consent_required",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST_URI,
+ want: "invalid_request_uri",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST_OBJECT,
+ want: "invalid_request_object",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_REQUEST_NOT_SUPPORTED,
+ want: "request_not_supported",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_REQUEST_URI_NOT_SUPPORTED,
+ want: "request_uri_not_supported",
+ },
+ {
+ reason: oidc_pb.ErrorReason_ERROR_REASON_REGISTRATION_NOT_SUPPORTED,
+ want: "registration_not_supported",
+ },
+ {
+ reason: 99999,
+ want: "server_error",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.reason.String(), func(t *testing.T) {
+ got := errorReasonToOIDC(tt.reason)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/internal/api/grpc/oidc/v2beta/server.go b/internal/api/grpc/oidc/v2beta/server.go
new file mode 100644
index 0000000000..7595ae927e
--- /dev/null
+++ b/internal/api/grpc/oidc/v2beta/server.go
@@ -0,0 +1,59 @@
+package oidc
+
+import (
+ "google.golang.org/grpc"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
+ "github.com/zitadel/zitadel/internal/api/grpc/server"
+ "github.com/zitadel/zitadel/internal/api/oidc"
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/query"
+ oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
+)
+
+var _ oidc_pb.OIDCServiceServer = (*Server)(nil)
+
+type Server struct {
+ oidc_pb.UnimplementedOIDCServiceServer
+ command *command.Commands
+ query *query.Queries
+
+ op *oidc.Server
+ externalSecure bool
+}
+
+type Config struct{}
+
+func CreateServer(
+ command *command.Commands,
+ query *query.Queries,
+ op *oidc.Server,
+ externalSecure bool,
+) *Server {
+ return &Server{
+ command: command,
+ query: query,
+ op: op,
+ externalSecure: externalSecure,
+ }
+}
+
+func (s *Server) RegisterServer(grpcServer *grpc.Server) {
+ oidc_pb.RegisterOIDCServiceServer(grpcServer, s)
+}
+
+func (s *Server) AppName() string {
+ return oidc_pb.OIDCService_ServiceDesc.ServiceName
+}
+
+func (s *Server) MethodPrefix() string {
+ return oidc_pb.OIDCService_ServiceDesc.ServiceName
+}
+
+func (s *Server) AuthMethods() authz.MethodMapping {
+ return oidc_pb.OIDCService_AuthMethods
+}
+
+func (s *Server) RegisterGateway() server.RegisterGatewayFunc {
+ return oidc_pb.RegisterOIDCServiceHandler
+}
diff --git a/internal/api/grpc/org/v2/org.go b/internal/api/grpc/org/v2/org.go
index 1fc0ca8aad..be830bc7b5 100644
--- a/internal/api/grpc/org/v2/org.go
+++ b/internal/api/grpc/org/v2/org.go
@@ -7,7 +7,7 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc/user/v2"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/zerrors"
- org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/org/v2"
)
func (s *Server) AddOrganization(ctx context.Context, request *org.AddOrganizationRequest) (*org.AddOrganizationResponse, error) {
diff --git a/internal/api/grpc/org/v2/org_integration_test.go b/internal/api/grpc/org/v2/org_integration_test.go
index 7276e8d5eb..9f3f9fa64b 100644
--- a/internal/api/grpc/org/v2/org_integration_test.go
+++ b/internal/api/grpc/org/v2/org_integration_test.go
@@ -14,8 +14,8 @@ import (
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/integration"
- org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/org/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
var (
diff --git a/internal/api/grpc/org/v2/org_test.go b/internal/api/grpc/org/v2/org_test.go
index 5024b59c1d..451c4006b3 100644
--- a/internal/api/grpc/org/v2/org_test.go
+++ b/internal/api/grpc/org/v2/org_test.go
@@ -12,9 +12,9 @@ import (
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/zerrors"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/org/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func Test_addOrganizationRequestToCommand(t *testing.T) {
diff --git a/internal/api/grpc/org/v2/server.go b/internal/api/grpc/org/v2/server.go
index 89dba81702..36588f3eb7 100644
--- a/internal/api/grpc/org/v2/server.go
+++ b/internal/api/grpc/org/v2/server.go
@@ -8,7 +8,7 @@ import (
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
- org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/org/v2"
)
var _ org.OrganizationServiceServer = (*Server)(nil)
diff --git a/internal/api/grpc/org/v2beta/org.go b/internal/api/grpc/org/v2beta/org.go
new file mode 100644
index 0000000000..ab2da2b766
--- /dev/null
+++ b/internal/api/grpc/org/v2beta/org.go
@@ -0,0 +1,83 @@
+package org
+
+import (
+ "context"
+
+ object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/internal/api/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/zerrors"
+ org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta"
+)
+
+func (s *Server) AddOrganization(ctx context.Context, request *org.AddOrganizationRequest) (*org.AddOrganizationResponse, error) {
+ orgSetup, err := addOrganizationRequestToCommand(request)
+ if err != nil {
+ return nil, err
+ }
+ createdOrg, err := s.command.SetUpOrg(ctx, orgSetup, false)
+ if err != nil {
+ return nil, err
+ }
+ return createdOrganizationToPb(createdOrg)
+}
+
+func addOrganizationRequestToCommand(request *org.AddOrganizationRequest) (*command.OrgSetup, error) {
+ admins, err := addOrganizationRequestAdminsToCommand(request.GetAdmins())
+ if err != nil {
+ return nil, err
+ }
+ return &command.OrgSetup{
+ Name: request.GetName(),
+ CustomDomain: "",
+ Admins: admins,
+ }, nil
+}
+
+func addOrganizationRequestAdminsToCommand(requestAdmins []*org.AddOrganizationRequest_Admin) (admins []*command.OrgSetupAdmin, err error) {
+ admins = make([]*command.OrgSetupAdmin, len(requestAdmins))
+ for i, admin := range requestAdmins {
+ admins[i], err = addOrganizationRequestAdminToCommand(admin)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return admins, nil
+}
+
+func addOrganizationRequestAdminToCommand(admin *org.AddOrganizationRequest_Admin) (*command.OrgSetupAdmin, error) {
+ switch a := admin.GetUserType().(type) {
+ case *org.AddOrganizationRequest_Admin_UserId:
+ return &command.OrgSetupAdmin{
+ ID: a.UserId,
+ Roles: admin.GetRoles(),
+ }, nil
+ case *org.AddOrganizationRequest_Admin_Human:
+ human, err := user.AddUserRequestToAddHuman(a.Human)
+ if err != nil {
+ return nil, err
+ }
+ return &command.OrgSetupAdmin{
+ Human: human,
+ Roles: admin.GetRoles(),
+ }, nil
+ default:
+ return nil, zerrors.ThrowUnimplementedf(nil, "ORGv2-SD2r1", "userType oneOf %T in method AddOrganization not implemented", a)
+ }
+}
+
+func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.AddOrganizationResponse, err error) {
+ admins := make([]*org.AddOrganizationResponse_CreatedAdmin, len(createdOrg.CreatedAdmins))
+ for i, admin := range createdOrg.CreatedAdmins {
+ admins[i] = &org.AddOrganizationResponse_CreatedAdmin{
+ UserId: admin.ID,
+ EmailCode: admin.EmailCode,
+ PhoneCode: admin.PhoneCode,
+ }
+ }
+ return &org.AddOrganizationResponse{
+ Details: object.DomainToDetailsPb(createdOrg.ObjectDetails),
+ OrganizationId: createdOrg.ObjectDetails.ResourceOwner,
+ CreatedAdmins: admins,
+ }, nil
+}
diff --git a/internal/api/grpc/org/v2beta/org_integration_test.go b/internal/api/grpc/org/v2beta/org_integration_test.go
new file mode 100644
index 0000000000..97f7e0a719
--- /dev/null
+++ b/internal/api/grpc/org/v2beta/org_integration_test.go
@@ -0,0 +1,207 @@
+//go:build integration
+
+package org_test
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/zitadel/zitadel/internal/integration"
+ org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+ user_v2beta "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+var (
+ CTX context.Context
+ Tester *integration.Tester
+ Client org.OrganizationServiceClient
+ User *user.AddHumanUserResponse
+)
+
+func TestMain(m *testing.M) {
+ os.Exit(func() int {
+ ctx, errCtx, cancel := integration.Contexts(5 * time.Minute)
+ defer cancel()
+
+ Tester = integration.NewTester(ctx)
+ defer Tester.Done()
+ Client = Tester.Client.OrgV2beta
+
+ CTX, _ = Tester.WithAuthorization(ctx, integration.IAMOwner), errCtx
+ User = Tester.CreateHumanUser(CTX)
+ return m.Run()
+ }())
+}
+
+func TestServer_AddOrganization(t *testing.T) {
+ idpID := Tester.AddGenericOAuthProvider(t, CTX)
+
+ tests := []struct {
+ name string
+ ctx context.Context
+ req *org.AddOrganizationRequest
+ want *org.AddOrganizationResponse
+ wantErr bool
+ }{
+ {
+ name: "missing permission",
+ ctx: Tester.WithAuthorization(context.Background(), integration.OrgOwner),
+ req: &org.AddOrganizationRequest{
+ Name: "name",
+ Admins: nil,
+ },
+ wantErr: true,
+ },
+ {
+ name: "empty name",
+ ctx: CTX,
+ req: &org.AddOrganizationRequest{
+ Name: "",
+ Admins: nil,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid admin type",
+ ctx: CTX,
+ req: &org.AddOrganizationRequest{
+ Name: fmt.Sprintf("%d", time.Now().UnixNano()),
+ Admins: []*org.AddOrganizationRequest_Admin{
+ {},
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "admin with init",
+ ctx: CTX,
+ req: &org.AddOrganizationRequest{
+ Name: fmt.Sprintf("%d", time.Now().UnixNano()),
+ Admins: []*org.AddOrganizationRequest_Admin{
+ {
+ UserType: &org.AddOrganizationRequest_Admin_Human{
+ Human: &user_v2beta.AddHumanUserRequest{
+ Profile: &user_v2beta.SetHumanProfile{
+ GivenName: "firstname",
+ FamilyName: "lastname",
+ },
+ Email: &user_v2beta.SetHumanEmail{
+ Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()),
+ Verification: &user_v2beta.SetHumanEmail_ReturnCode{
+ ReturnCode: &user_v2beta.ReturnEmailVerificationCode{},
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ want: &org.AddOrganizationResponse{
+ OrganizationId: integration.NotEmpty,
+ CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{
+ {
+ UserId: integration.NotEmpty,
+ EmailCode: gu.Ptr(integration.NotEmpty),
+ PhoneCode: nil,
+ },
+ },
+ },
+ },
+ {
+ name: "existing user and new human with idp",
+ ctx: CTX,
+ req: &org.AddOrganizationRequest{
+ Name: fmt.Sprintf("%d", time.Now().UnixNano()),
+ Admins: []*org.AddOrganizationRequest_Admin{
+ {
+ UserType: &org.AddOrganizationRequest_Admin_UserId{UserId: User.GetUserId()},
+ },
+ {
+ UserType: &org.AddOrganizationRequest_Admin_Human{
+ Human: &user_v2beta.AddHumanUserRequest{
+ Profile: &user_v2beta.SetHumanProfile{
+ GivenName: "firstname",
+ FamilyName: "lastname",
+ },
+ Email: &user_v2beta.SetHumanEmail{
+ Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()),
+ Verification: &user_v2beta.SetHumanEmail_IsVerified{
+ IsVerified: true,
+ },
+ },
+ IdpLinks: []*user_v2beta.IDPLink{
+ {
+ IdpId: idpID,
+ UserId: "userID",
+ UserName: "username",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ want: &org.AddOrganizationResponse{
+ CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{
+ // a single admin is expected, because the first provided already exists
+ {
+ UserId: integration.NotEmpty,
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.AddOrganization(tt.ctx, tt.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+
+ // check details
+ assert.NotZero(t, got.GetDetails().GetSequence())
+ gotCD := got.GetDetails().GetChangeDate().AsTime()
+ now := time.Now()
+ assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute))
+ assert.NotEmpty(t, got.GetDetails().GetResourceOwner())
+
+ // organization id must be the same as the resourceOwner
+ assert.Equal(t, got.GetDetails().GetResourceOwner(), got.GetOrganizationId())
+
+ // check the admins
+ require.Len(t, got.GetCreatedAdmins(), len(tt.want.GetCreatedAdmins()))
+ for i, admin := range tt.want.GetCreatedAdmins() {
+ gotAdmin := got.GetCreatedAdmins()[i]
+ assertCreatedAdmin(t, admin, gotAdmin)
+ }
+ })
+ }
+}
+
+func assertCreatedAdmin(t *testing.T, expected, got *org.AddOrganizationResponse_CreatedAdmin) {
+ if expected.GetUserId() != "" {
+ assert.NotEmpty(t, got.GetUserId())
+ } else {
+ assert.Empty(t, got.GetUserId())
+ }
+ if expected.GetEmailCode() != "" {
+ assert.NotEmpty(t, got.GetEmailCode())
+ } else {
+ assert.Empty(t, got.GetEmailCode())
+ }
+ if expected.GetPhoneCode() != "" {
+ assert.NotEmpty(t, got.GetPhoneCode())
+ } else {
+ assert.Empty(t, got.GetPhoneCode())
+ }
+}
diff --git a/internal/api/grpc/org/v2beta/org_test.go b/internal/api/grpc/org/v2beta/org_test.go
new file mode 100644
index 0000000000..5024b59c1d
--- /dev/null
+++ b/internal/api/grpc/org/v2beta/org_test.go
@@ -0,0 +1,172 @@
+package org
+
+import (
+ "testing"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/zerrors"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func Test_addOrganizationRequestToCommand(t *testing.T) {
+ type args struct {
+ request *org.AddOrganizationRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *command.OrgSetup
+ wantErr error
+ }{
+ {
+ name: "nil user",
+ args: args{
+ request: &org.AddOrganizationRequest{
+ Name: "name",
+ Admins: []*org.AddOrganizationRequest_Admin{
+ {},
+ },
+ },
+ },
+ wantErr: zerrors.ThrowUnimplementedf(nil, "ORGv2-SD2r1", "userType oneOf %T in method AddOrganization not implemented", nil),
+ },
+ {
+ name: "user ID",
+ args: args{
+ request: &org.AddOrganizationRequest{
+ Name: "name",
+ Admins: []*org.AddOrganizationRequest_Admin{
+ {
+ UserType: &org.AddOrganizationRequest_Admin_UserId{
+ UserId: "userID",
+ },
+ Roles: nil,
+ },
+ },
+ },
+ },
+ want: &command.OrgSetup{
+ Name: "name",
+ CustomDomain: "",
+ Admins: []*command.OrgSetupAdmin{
+ {
+ ID: "userID",
+ },
+ },
+ },
+ },
+ {
+ name: "human user",
+ args: args{
+ request: &org.AddOrganizationRequest{
+ Name: "name",
+ Admins: []*org.AddOrganizationRequest_Admin{
+ {
+ UserType: &org.AddOrganizationRequest_Admin_Human{
+ Human: &user.AddHumanUserRequest{
+ Profile: &user.SetHumanProfile{
+ GivenName: "firstname",
+ FamilyName: "lastname",
+ },
+ Email: &user.SetHumanEmail{
+ Email: "email@test.com",
+ },
+ },
+ },
+ Roles: nil,
+ },
+ },
+ },
+ },
+ want: &command.OrgSetup{
+ Name: "name",
+ CustomDomain: "",
+ Admins: []*command.OrgSetupAdmin{
+ {
+ Human: &command.AddHuman{
+ Username: "email@test.com",
+ FirstName: "firstname",
+ LastName: "lastname",
+ Email: command.Email{
+ Address: "email@test.com",
+ },
+ Metadata: make([]*command.AddMetadataEntry, 0),
+ Links: make([]*command.AddLink, 0),
+ },
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := addOrganizationRequestToCommand(tt.args.request)
+ require.ErrorIs(t, err, tt.wantErr)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func Test_createdOrganizationToPb(t *testing.T) {
+ now := time.Now()
+ type args struct {
+ createdOrg *command.CreatedOrg
+ }
+ tests := []struct {
+ name string
+ args args
+ want *org.AddOrganizationResponse
+ wantErr error
+ }{
+ {
+ name: "human user with phone and email code",
+ args: args{
+ createdOrg: &command.CreatedOrg{
+ ObjectDetails: &domain.ObjectDetails{
+ Sequence: 1,
+ EventDate: now,
+ ResourceOwner: "orgID",
+ },
+ CreatedAdmins: []*command.CreatedOrgAdmin{
+ {
+ ID: "id",
+ EmailCode: gu.Ptr("emailCode"),
+ PhoneCode: gu.Ptr("phoneCode"),
+ },
+ },
+ },
+ },
+ want: &org.AddOrganizationResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.New(now),
+ ResourceOwner: "orgID",
+ },
+ OrganizationId: "orgID",
+ CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{
+ {
+ UserId: "id",
+ EmailCode: gu.Ptr("emailCode"),
+ PhoneCode: gu.Ptr("phoneCode"),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := createdOrganizationToPb(tt.args.createdOrg)
+ require.ErrorIs(t, err, tt.wantErr)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/internal/api/grpc/org/v2beta/server.go b/internal/api/grpc/org/v2beta/server.go
new file mode 100644
index 0000000000..89dba81702
--- /dev/null
+++ b/internal/api/grpc/org/v2beta/server.go
@@ -0,0 +1,55 @@
+package org
+
+import (
+ "google.golang.org/grpc"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
+ "github.com/zitadel/zitadel/internal/api/grpc/server"
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/query"
+ org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta"
+)
+
+var _ org.OrganizationServiceServer = (*Server)(nil)
+
+type Server struct {
+ org.UnimplementedOrganizationServiceServer
+ command *command.Commands
+ query *query.Queries
+ checkPermission domain.PermissionCheck
+}
+
+type Config struct{}
+
+func CreateServer(
+ command *command.Commands,
+ query *query.Queries,
+ checkPermission domain.PermissionCheck,
+) *Server {
+ return &Server{
+ command: command,
+ query: query,
+ checkPermission: checkPermission,
+ }
+}
+
+func (s *Server) RegisterServer(grpcServer *grpc.Server) {
+ org.RegisterOrganizationServiceServer(grpcServer, s)
+}
+
+func (s *Server) AppName() string {
+ return org.OrganizationService_ServiceDesc.ServiceName
+}
+
+func (s *Server) MethodPrefix() string {
+ return org.OrganizationService_ServiceDesc.ServiceName
+}
+
+func (s *Server) AuthMethods() authz.MethodMapping {
+ return org.OrganizationService_AuthMethods
+}
+
+func (s *Server) RegisterGateway() server.RegisterGatewayFunc {
+ return org.RegisterOrganizationServiceHandler
+}
diff --git a/internal/api/grpc/server/middleware/activity_interceptor.go b/internal/api/grpc/server/middleware/activity_interceptor.go
index 7b8b164e99..29b7612eef 100644
--- a/internal/api/grpc/server/middleware/activity_interceptor.go
+++ b/internal/api/grpc/server/middleware/activity_interceptor.go
@@ -29,6 +29,8 @@ func ActivityInterceptor() grpc.UnaryServerInterceptor {
var resourcePrefixes = []string{
"/zitadel.management.v1.ManagementService/",
"/zitadel.admin.v1.AdminService/",
+ "/zitadel.user.v2.UserService/",
+ "/zitadel.settings.v2.SettingsService/",
"/zitadel.user.v2beta.UserService/",
"/zitadel.settings.v2beta.SettingsService/",
"/zitadel.auth.v1.AuthService/",
diff --git a/internal/api/grpc/server/middleware/execution_interceptor_test.go b/internal/api/grpc/server/middleware/execution_interceptor_test.go
index bbc87c374f..f59fd00441 100644
--- a/internal/api/grpc/server/middleware/execution_interceptor_test.go
+++ b/internal/api/grpc/server/middleware/execution_interceptor_test.go
@@ -131,7 +131,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
executionTargets: []execution.Target{
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: domain.TargetTypeCall,
Timeout: time.Minute,
@@ -153,7 +153,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
executionTargets: []execution.Target{
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: domain.TargetTypeCall,
Timeout: time.Minute,
@@ -181,7 +181,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
executionTargets: []execution.Target{
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: domain.TargetTypeCall,
Timeout: time.Minute,
@@ -211,7 +211,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
executionTargets: []execution.Target{
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: domain.TargetTypeCall,
Timeout: time.Second,
@@ -240,7 +240,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
executionTargets: []execution.Target{
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: domain.TargetTypeCall,
Timeout: time.Second,
@@ -264,7 +264,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
executionTargets: []execution.Target{
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: domain.TargetTypeCall,
Timeout: time.Minute,
@@ -293,7 +293,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
executionTargets: []execution.Target{
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: domain.TargetTypeAsync,
Timeout: time.Second,
@@ -321,7 +321,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
executionTargets: []execution.Target{
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: domain.TargetTypeAsync,
Timeout: time.Minute,
@@ -349,7 +349,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
executionTargets: []execution.Target{
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: domain.TargetTypeWebhook,
Timeout: time.Minute,
@@ -377,7 +377,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
executionTargets: []execution.Target{
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: domain.TargetTypeWebhook,
Timeout: time.Second,
@@ -406,7 +406,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
executionTargets: []execution.Target{
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: domain.TargetTypeWebhook,
Timeout: time.Minute,
@@ -435,7 +435,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
executionTargets: []execution.Target{
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target1",
TargetType: domain.TargetTypeCall,
Timeout: time.Minute,
@@ -443,7 +443,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
},
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target2",
TargetType: domain.TargetTypeCall,
Timeout: time.Minute,
@@ -451,7 +451,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
},
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target3",
TargetType: domain.TargetTypeCall,
Timeout: time.Minute,
@@ -493,7 +493,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
executionTargets: []execution.Target{
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target1",
TargetType: domain.TargetTypeCall,
Timeout: time.Minute,
@@ -501,7 +501,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
},
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target2",
TargetType: domain.TargetTypeCall,
Timeout: time.Second,
@@ -509,7 +509,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
},
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target3",
TargetType: domain.TargetTypeCall,
Timeout: time.Second,
@@ -687,7 +687,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) {
executionTargets: []execution.Target{
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "request./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: domain.TargetTypeCall,
Timeout: time.Minute,
@@ -716,7 +716,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) {
executionTargets: []execution.Target{
&mockExecutionTarget{
InstanceID: "instance",
- ExecutionID: "response./zitadel.session.v2beta.SessionService/SetSession",
+ ExecutionID: "response./zitadel.session.v2.SessionService/SetSession",
TargetID: "target",
TargetType: domain.TargetTypeCall,
Timeout: time.Minute,
diff --git a/internal/api/grpc/session/v2/server.go b/internal/api/grpc/session/v2/server.go
index 550d013ad5..e94336bf47 100644
--- a/internal/api/grpc/session/v2/server.go
+++ b/internal/api/grpc/session/v2/server.go
@@ -7,7 +7,7 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc/server"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/query"
- session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/session/v2"
)
var _ session.SessionServiceServer = (*Server)(nil)
diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go
index 7af87798e6..aa25fa0ae3 100644
--- a/internal/api/grpc/session/v2/session.go
+++ b/internal/api/grpc/session/v2/session.go
@@ -18,7 +18,7 @@ import (
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
objpb "github.com/zitadel/zitadel/pkg/grpc/object"
- session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/session/v2"
)
var (
diff --git a/internal/api/grpc/session/v2/session_integration_test.go b/internal/api/grpc/session/v2/session_integration_test.go
index 92d3b0baf7..0871f92994 100644
--- a/internal/api/grpc/session/v2/session_integration_test.go
+++ b/internal/api/grpc/session/v2/session_integration_test.go
@@ -23,9 +23,9 @@ import (
"github.com/zitadel/zitadel/internal/integration"
mgmt "github.com/zitadel/zitadel/pkg/grpc/management"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/session/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
var (
@@ -860,7 +860,7 @@ func TestServer_SetSession_expired(t *testing.T) {
// ensure session expires and does not work anymore
time.Sleep(20 * time.Second)
- _, err = Tester.Client.SessionV2.SetSession(CTX, &session.SetSessionRequest{
+ _, err = Client.SetSession(CTX, &session.SetSessionRequest{
SessionId: createResp.GetSessionId(),
Lifetime: durationpb.New(20 * time.Second),
})
@@ -944,7 +944,7 @@ func Test_ZITADEL_API_missing_authentication(t *testing.T) {
require.NoError(t, err)
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", createResp.GetSessionToken()))
- sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()})
+ sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()})
require.Error(t, err)
require.Nil(t, sessionResp)
}
@@ -953,7 +953,7 @@ func Test_ZITADEL_API_success(t *testing.T) {
id, token, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, User.GetUserId())
ctx := Tester.WithAuthorizationToken(context.Background(), token)
- sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
+ sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
require.NoError(t, err)
webAuthN := sessionResp.GetSession().GetFactors().GetWebAuthN()
@@ -966,17 +966,17 @@ func Test_ZITADEL_API_session_not_found(t *testing.T) {
// test session token works
ctx := Tester.WithAuthorizationToken(context.Background(), token)
- _, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
+ _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
require.NoError(t, err)
//terminate the session and test it does not work anymore
- _, err = Tester.Client.SessionV2.DeleteSession(CTX, &session.DeleteSessionRequest{
+ _, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{
SessionId: id,
SessionToken: gu.Ptr(token),
})
require.NoError(t, err)
ctx = Tester.WithAuthorizationToken(context.Background(), token)
- _, err = Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
+ _, err = Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
require.Error(t, err)
}
@@ -985,12 +985,12 @@ func Test_ZITADEL_API_session_expired(t *testing.T) {
// test session token works
ctx := Tester.WithAuthorizationToken(context.Background(), token)
- _, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
+ _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
require.NoError(t, err)
// ensure session expires and does not work anymore
time.Sleep(20 * time.Second)
- sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
+ sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
require.Error(t, err)
require.Nil(t, sessionResp)
}
diff --git a/internal/api/grpc/session/v2/session_test.go b/internal/api/grpc/session/v2/session_test.go
index c088b5b886..917be882f8 100644
--- a/internal/api/grpc/session/v2/session_test.go
+++ b/internal/api/grpc/session/v2/session_test.go
@@ -18,8 +18,8 @@ import (
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
objpb "github.com/zitadel/zitadel/pkg/grpc/object"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/session/v2"
)
var (
diff --git a/internal/api/grpc/session/v2beta/server.go b/internal/api/grpc/session/v2beta/server.go
new file mode 100644
index 0000000000..550d013ad5
--- /dev/null
+++ b/internal/api/grpc/session/v2beta/server.go
@@ -0,0 +1,51 @@
+package session
+
+import (
+ "google.golang.org/grpc"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
+ "github.com/zitadel/zitadel/internal/api/grpc/server"
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/query"
+ session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
+)
+
+var _ session.SessionServiceServer = (*Server)(nil)
+
+type Server struct {
+ session.UnimplementedSessionServiceServer
+ command *command.Commands
+ query *query.Queries
+}
+
+type Config struct{}
+
+func CreateServer(
+ command *command.Commands,
+ query *query.Queries,
+) *Server {
+ return &Server{
+ command: command,
+ query: query,
+ }
+}
+
+func (s *Server) RegisterServer(grpcServer *grpc.Server) {
+ session.RegisterSessionServiceServer(grpcServer, s)
+}
+
+func (s *Server) AppName() string {
+ return session.SessionService_ServiceDesc.ServiceName
+}
+
+func (s *Server) MethodPrefix() string {
+ return session.SessionService_ServiceDesc.ServiceName
+}
+
+func (s *Server) AuthMethods() authz.MethodMapping {
+ return session.SessionService_AuthMethods
+}
+
+func (s *Server) RegisterGateway() server.RegisterGatewayFunc {
+ return session.RegisterSessionServiceHandler
+}
diff --git a/internal/api/grpc/session/v2beta/session.go b/internal/api/grpc/session/v2beta/session.go
new file mode 100644
index 0000000000..7e67a4b3ff
--- /dev/null
+++ b/internal/api/grpc/session/v2beta/session.go
@@ -0,0 +1,500 @@
+package session
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "golang.org/x/text/language"
+ "google.golang.org/protobuf/types/known/structpb"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
+ object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/query"
+ "github.com/zitadel/zitadel/internal/zerrors"
+ objpb "github.com/zitadel/zitadel/pkg/grpc/object"
+ session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
+)
+
+var (
+ timestampComparisons = map[objpb.TimestampQueryMethod]query.TimestampComparison{
+ objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_EQUALS: query.TimestampEquals,
+ objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER: query.TimestampGreater,
+ objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER_OR_EQUALS: query.TimestampGreaterOrEquals,
+ objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS: query.TimestampLess,
+ objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS_OR_EQUALS: query.TimestampLessOrEquals,
+ }
+)
+
+func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) {
+ res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken())
+ if err != nil {
+ return nil, err
+ }
+ return &session.GetSessionResponse{
+ Session: sessionToPb(res),
+ }, nil
+}
+
+func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequest) (*session.ListSessionsResponse, error) {
+ queries, err := listSessionsRequestToQuery(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ sessions, err := s.query.SearchSessions(ctx, queries)
+ if err != nil {
+ return nil, err
+ }
+ return &session.ListSessionsResponse{
+ Details: object.ToListDetails(sessions.SearchResponse),
+ Sessions: sessionsToPb(sessions.Sessions),
+ }, nil
+}
+
+func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRequest) (*session.CreateSessionResponse, error) {
+ checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks)
+ if err != nil {
+ return nil, err
+ }
+
+ set, err := s.command.CreateSession(ctx, cmds, metadata, userAgent, lifetime)
+ if err != nil {
+ return nil, err
+ }
+
+ return &session.CreateSessionResponse{
+ Details: object.DomainToDetailsPb(set.ObjectDetails),
+ SessionId: set.ID,
+ SessionToken: set.NewToken,
+ Challenges: challengeResponse,
+ }, nil
+}
+
+func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest) (*session.SetSessionResponse, error) {
+ checks, err := s.setSessionRequestToCommand(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks)
+ if err != nil {
+ return nil, err
+ }
+
+ set, err := s.command.UpdateSession(ctx, req.GetSessionId(), cmds, req.GetMetadata(), req.GetLifetime().AsDuration())
+ if err != nil {
+ return nil, err
+ }
+ return &session.SetSessionResponse{
+ Details: object.DomainToDetailsPb(set.ObjectDetails),
+ SessionToken: set.NewToken,
+ Challenges: challengeResponse,
+ }, nil
+}
+
+func (s *Server) DeleteSession(ctx context.Context, req *session.DeleteSessionRequest) (*session.DeleteSessionResponse, error) {
+ details, err := s.command.TerminateSession(ctx, req.GetSessionId(), req.GetSessionToken())
+ if err != nil {
+ return nil, err
+ }
+ return &session.DeleteSessionResponse{
+ Details: object.DomainToDetailsPb(details),
+ }, nil
+}
+
+func sessionsToPb(sessions []*query.Session) []*session.Session {
+ s := make([]*session.Session, len(sessions))
+ for i, session := range sessions {
+ s[i] = sessionToPb(session)
+ }
+ return s
+}
+
+func sessionToPb(s *query.Session) *session.Session {
+ return &session.Session{
+ Id: s.ID,
+ CreationDate: timestamppb.New(s.CreationDate),
+ ChangeDate: timestamppb.New(s.ChangeDate),
+ Sequence: s.Sequence,
+ Factors: factorsToPb(s),
+ Metadata: s.Metadata,
+ UserAgent: userAgentToPb(s.UserAgent),
+ ExpirationDate: expirationToPb(s.Expiration),
+ }
+}
+
+func userAgentToPb(ua domain.UserAgent) *session.UserAgent {
+ if ua.IsEmpty() {
+ return nil
+ }
+
+ out := &session.UserAgent{
+ FingerprintId: ua.FingerprintID,
+ Description: ua.Description,
+ }
+ if ua.IP != nil {
+ out.Ip = gu.Ptr(ua.IP.String())
+ }
+ if ua.Header == nil {
+ return out
+ }
+ out.Header = make(map[string]*session.UserAgent_HeaderValues, len(ua.Header))
+ for k, v := range ua.Header {
+ out.Header[k] = &session.UserAgent_HeaderValues{
+ Values: v,
+ }
+ }
+ return out
+}
+
+func expirationToPb(expiration time.Time) *timestamppb.Timestamp {
+ if expiration.IsZero() {
+ return nil
+ }
+ return timestamppb.New(expiration)
+}
+
+func factorsToPb(s *query.Session) *session.Factors {
+ user := userFactorToPb(s.UserFactor)
+ if user == nil {
+ return nil
+ }
+ return &session.Factors{
+ User: user,
+ Password: passwordFactorToPb(s.PasswordFactor),
+ WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor),
+ Intent: intentFactorToPb(s.IntentFactor),
+ Totp: totpFactorToPb(s.TOTPFactor),
+ OtpSms: otpFactorToPb(s.OTPSMSFactor),
+ OtpEmail: otpFactorToPb(s.OTPEmailFactor),
+ }
+}
+
+func passwordFactorToPb(factor query.SessionPasswordFactor) *session.PasswordFactor {
+ if factor.PasswordCheckedAt.IsZero() {
+ return nil
+ }
+ return &session.PasswordFactor{
+ VerifiedAt: timestamppb.New(factor.PasswordCheckedAt),
+ }
+}
+
+func intentFactorToPb(factor query.SessionIntentFactor) *session.IntentFactor {
+ if factor.IntentCheckedAt.IsZero() {
+ return nil
+ }
+ return &session.IntentFactor{
+ VerifiedAt: timestamppb.New(factor.IntentCheckedAt),
+ }
+}
+
+func webAuthNFactorToPb(factor query.SessionWebAuthNFactor) *session.WebAuthNFactor {
+ if factor.WebAuthNCheckedAt.IsZero() {
+ return nil
+ }
+ return &session.WebAuthNFactor{
+ VerifiedAt: timestamppb.New(factor.WebAuthNCheckedAt),
+ UserVerified: factor.UserVerified,
+ }
+}
+
+func totpFactorToPb(factor query.SessionTOTPFactor) *session.TOTPFactor {
+ if factor.TOTPCheckedAt.IsZero() {
+ return nil
+ }
+ return &session.TOTPFactor{
+ VerifiedAt: timestamppb.New(factor.TOTPCheckedAt),
+ }
+}
+
+func otpFactorToPb(factor query.SessionOTPFactor) *session.OTPFactor {
+ if factor.OTPCheckedAt.IsZero() {
+ return nil
+ }
+ return &session.OTPFactor{
+ VerifiedAt: timestamppb.New(factor.OTPCheckedAt),
+ }
+}
+
+func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor {
+ if factor.UserID == "" || factor.UserCheckedAt.IsZero() {
+ return nil
+ }
+ return &session.UserFactor{
+ VerifiedAt: timestamppb.New(factor.UserCheckedAt),
+ Id: factor.UserID,
+ LoginName: factor.LoginName,
+ DisplayName: factor.DisplayName,
+ OrganizationId: factor.ResourceOwner,
+ }
+}
+
+func listSessionsRequestToQuery(ctx context.Context, req *session.ListSessionsRequest) (*query.SessionsSearchQueries, error) {
+ offset, limit, asc := object.ListQueryToQuery(req.Query)
+ queries, err := sessionQueriesToQuery(ctx, req.GetQueries())
+ if err != nil {
+ return nil, err
+ }
+ return &query.SessionsSearchQueries{
+ SearchRequest: query.SearchRequest{
+ Offset: offset,
+ Limit: limit,
+ Asc: asc,
+ SortingColumn: fieldNameToSessionColumn(req.GetSortingColumn()),
+ },
+ Queries: queries,
+ }, nil
+}
+
+func sessionQueriesToQuery(ctx context.Context, queries []*session.SearchQuery) (_ []query.SearchQuery, err error) {
+ q := make([]query.SearchQuery, len(queries)+1)
+ for i, v := range queries {
+ q[i], err = sessionQueryToQuery(v)
+ if err != nil {
+ return nil, err
+ }
+ }
+ creatorQuery, err := query.NewSessionCreatorSearchQuery(authz.GetCtxData(ctx).UserID)
+ if err != nil {
+ return nil, err
+ }
+ q[len(queries)] = creatorQuery
+ return q, nil
+}
+
+func sessionQueryToQuery(sq *session.SearchQuery) (query.SearchQuery, error) {
+ switch q := sq.Query.(type) {
+ case *session.SearchQuery_IdsQuery:
+ return idsQueryToQuery(q.IdsQuery)
+ case *session.SearchQuery_UserIdQuery:
+ return query.NewUserIDSearchQuery(q.UserIdQuery.GetId())
+ case *session.SearchQuery_CreationDateQuery:
+ return creationDateQueryToQuery(q.CreationDateQuery)
+ default:
+ return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid")
+ }
+}
+
+func idsQueryToQuery(q *session.IDsQuery) (query.SearchQuery, error) {
+ return query.NewSessionIDsSearchQuery(q.Ids)
+}
+
+func creationDateQueryToQuery(q *session.CreationDateQuery) (query.SearchQuery, error) {
+ comparison := timestampComparisons[q.GetMethod()]
+ return query.NewCreationDateQuery(q.GetCreationDate().AsTime(), comparison)
+}
+
+func fieldNameToSessionColumn(field session.SessionFieldName) query.Column {
+ switch field {
+ case session.SessionFieldName_SESSION_FIELD_NAME_CREATION_DATE:
+ return query.SessionColumnCreationDate
+ case session.SessionFieldName_SESSION_FIELD_NAME_UNSPECIFIED:
+ // Handle all remaining cases so the linter succeeds
+ return query.Column{}
+ default:
+ return query.Column{}
+ }
+}
+
+func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCommand, map[string][]byte, *domain.UserAgent, time.Duration, error) {
+ checks, err := s.checksToCommand(ctx, req.Checks)
+ if err != nil {
+ return nil, nil, nil, 0, err
+ }
+ return checks, req.GetMetadata(), userAgentToCommand(req.GetUserAgent()), req.GetLifetime().AsDuration(), nil
+}
+
+func userAgentToCommand(userAgent *session.UserAgent) *domain.UserAgent {
+ if userAgent == nil {
+ return nil
+ }
+ out := &domain.UserAgent{
+ FingerprintID: userAgent.FingerprintId,
+ IP: net.ParseIP(userAgent.GetIp()),
+ Description: userAgent.Description,
+ }
+ if len(userAgent.Header) > 0 {
+ out.Header = make(http.Header, len(userAgent.Header))
+ for k, values := range userAgent.Header {
+ out.Header[k] = values.GetValues()
+ }
+ }
+ return out
+}
+
+func (s *Server) setSessionRequestToCommand(ctx context.Context, req *session.SetSessionRequest) ([]command.SessionCommand, error) {
+ checks, err := s.checksToCommand(ctx, req.Checks)
+ if err != nil {
+ return nil, err
+ }
+ return checks, nil
+}
+
+func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([]command.SessionCommand, error) {
+ checkUser, err := userCheck(checks.GetUser())
+ if err != nil {
+ return nil, err
+ }
+ sessionChecks := make([]command.SessionCommand, 0, 7)
+ if checkUser != nil {
+ user, err := checkUser.search(ctx, s.query)
+ if err != nil {
+ return nil, err
+ }
+ if !user.State.IsEnabled() {
+ return nil, zerrors.ThrowPreconditionFailed(nil, "SESSION-Gj4ko", "Errors.User.NotActive")
+ }
+
+ var preferredLanguage *language.Tag
+ if user.Human != nil && !user.Human.PreferredLanguage.IsRoot() {
+ preferredLanguage = &user.Human.PreferredLanguage
+ }
+ sessionChecks = append(sessionChecks, command.CheckUser(user.ID, user.ResourceOwner, preferredLanguage))
+ }
+ if password := checks.GetPassword(); password != nil {
+ sessionChecks = append(sessionChecks, command.CheckPassword(password.GetPassword()))
+ }
+ if intent := checks.GetIdpIntent(); intent != nil {
+ sessionChecks = append(sessionChecks, command.CheckIntent(intent.GetIdpIntentId(), intent.GetIdpIntentToken()))
+ }
+ if passkey := checks.GetWebAuthN(); passkey != nil {
+ sessionChecks = append(sessionChecks, s.command.CheckWebAuthN(passkey.GetCredentialAssertionData()))
+ }
+ if totp := checks.GetTotp(); totp != nil {
+ sessionChecks = append(sessionChecks, command.CheckTOTP(totp.GetCode()))
+ }
+ if otp := checks.GetOtpSms(); otp != nil {
+ sessionChecks = append(sessionChecks, command.CheckOTPSMS(otp.GetCode()))
+ }
+ if otp := checks.GetOtpEmail(); otp != nil {
+ sessionChecks = append(sessionChecks, command.CheckOTPEmail(otp.GetCode()))
+ }
+ return sessionChecks, nil
+}
+
+func (s *Server) challengesToCommand(challenges *session.RequestChallenges, cmds []command.SessionCommand) (*session.Challenges, []command.SessionCommand, error) {
+ if challenges == nil {
+ return nil, cmds, nil
+ }
+ resp := new(session.Challenges)
+ if req := challenges.GetWebAuthN(); req != nil {
+ challenge, cmd := s.createWebAuthNChallengeCommand(req)
+ resp.WebAuthN = challenge
+ cmds = append(cmds, cmd)
+ }
+ if req := challenges.GetOtpSms(); req != nil {
+ challenge, cmd := s.createOTPSMSChallengeCommand(req)
+ resp.OtpSms = challenge
+ cmds = append(cmds, cmd)
+ }
+ if req := challenges.GetOtpEmail(); req != nil {
+ challenge, cmd, err := s.createOTPEmailChallengeCommand(req)
+ if err != nil {
+ return nil, nil, err
+ }
+ resp.OtpEmail = challenge
+ cmds = append(cmds, cmd)
+ }
+ return resp, cmds, nil
+}
+
+func (s *Server) createWebAuthNChallengeCommand(req *session.RequestChallenges_WebAuthN) (*session.Challenges_WebAuthN, command.SessionCommand) {
+ challenge := &session.Challenges_WebAuthN{
+ PublicKeyCredentialRequestOptions: new(structpb.Struct),
+ }
+ userVerification := userVerificationRequirementToDomain(req.GetUserVerificationRequirement())
+ return challenge, s.command.CreateWebAuthNChallenge(userVerification, req.GetDomain(), challenge.PublicKeyCredentialRequestOptions)
+}
+
+func userVerificationRequirementToDomain(req session.UserVerificationRequirement) domain.UserVerificationRequirement {
+ switch req {
+ case session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_UNSPECIFIED:
+ return domain.UserVerificationRequirementUnspecified
+ case session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED:
+ return domain.UserVerificationRequirementRequired
+ case session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_PREFERRED:
+ return domain.UserVerificationRequirementPreferred
+ case session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_DISCOURAGED:
+ return domain.UserVerificationRequirementDiscouraged
+ default:
+ return domain.UserVerificationRequirementUnspecified
+ }
+}
+
+func (s *Server) createOTPSMSChallengeCommand(req *session.RequestChallenges_OTPSMS) (*string, command.SessionCommand) {
+ if req.GetReturnCode() {
+ challenge := new(string)
+ return challenge, s.command.CreateOTPSMSChallengeReturnCode(challenge)
+ }
+
+ return nil, s.command.CreateOTPSMSChallenge()
+
+}
+
+func (s *Server) createOTPEmailChallengeCommand(req *session.RequestChallenges_OTPEmail) (*string, command.SessionCommand, error) {
+ switch t := req.GetDeliveryType().(type) {
+ case *session.RequestChallenges_OTPEmail_SendCode_:
+ cmd, err := s.command.CreateOTPEmailChallengeURLTemplate(t.SendCode.GetUrlTemplate())
+ if err != nil {
+ return nil, nil, err
+ }
+ return nil, cmd, nil
+ case *session.RequestChallenges_OTPEmail_ReturnCode_:
+ challenge := new(string)
+ return challenge, s.command.CreateOTPEmailChallengeReturnCode(challenge), nil
+ case nil:
+ return nil, s.command.CreateOTPEmailChallenge(), nil
+ default:
+ return nil, nil, zerrors.ThrowUnimplementedf(nil, "SESSION-k3ng0", "delivery_type oneOf %T in OTPEmailChallenge not implemented", t)
+ }
+}
+
+func userCheck(user *session.CheckUser) (userSearch, error) {
+ if user == nil {
+ return nil, nil
+ }
+ switch s := user.GetSearch().(type) {
+ case *session.CheckUser_UserId:
+ return userByID(s.UserId), nil
+ case *session.CheckUser_LoginName:
+ return userByLoginName(s.LoginName)
+ default:
+ return nil, zerrors.ThrowUnimplementedf(nil, "SESSION-d3b4g0", "user search %T not implemented", s)
+ }
+}
+
+type userSearch interface {
+ search(ctx context.Context, q *query.Queries) (*query.User, error)
+}
+
+func userByID(userID string) userSearch {
+ return userSearchByID{userID}
+}
+
+func userByLoginName(loginName string) (userSearch, error) {
+ return userSearchByLoginName{loginName}, nil
+}
+
+type userSearchByID struct {
+ id string
+}
+
+func (u userSearchByID) search(ctx context.Context, q *query.Queries) (*query.User, error) {
+ return q.GetUserByID(ctx, true, u.id)
+}
+
+type userSearchByLoginName struct {
+ loginName string
+}
+
+func (u userSearchByLoginName) search(ctx context.Context, q *query.Queries) (*query.User, error) {
+ return q.GetUserByLoginName(ctx, true, u.loginName)
+}
diff --git a/internal/api/grpc/session/v2beta/session_integration_test.go b/internal/api/grpc/session/v2beta/session_integration_test.go
new file mode 100644
index 0000000000..94ecdf5410
--- /dev/null
+++ b/internal/api/grpc/session/v2beta/session_integration_test.go
@@ -0,0 +1,996 @@
+//go:build integration
+
+package session_test
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "github.com/pquerna/otp/totp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/zitadel/logging"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/status"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/types/known/durationpb"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/integration"
+ mgmt "github.com/zitadel/zitadel/pkg/grpc/management"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+)
+
+var (
+ CTX context.Context
+ IAMOwnerCTX context.Context
+ Tester *integration.Tester
+ Client session.SessionServiceClient
+ User *user.AddHumanUserResponse
+ DeactivatedUser *user.AddHumanUserResponse
+ LockedUser *user.AddHumanUserResponse
+)
+
+func TestMain(m *testing.M) {
+ os.Exit(func() int {
+ ctx, errCtx, cancel := integration.Contexts(5 * time.Minute)
+ defer cancel()
+
+ Tester = integration.NewTester(ctx)
+ defer Tester.Done()
+ Client = Tester.Client.SessionV2beta
+
+ CTX, _ = Tester.WithAuthorization(ctx, integration.OrgOwner), errCtx
+ IAMOwnerCTX = Tester.WithAuthorization(ctx, integration.IAMOwner)
+ User = createFullUser(CTX)
+ DeactivatedUser = createDeactivatedUser(CTX)
+ LockedUser = createLockedUser(CTX)
+ return m.Run()
+ }())
+}
+
+func createFullUser(ctx context.Context) *user.AddHumanUserResponse {
+ userResp := Tester.CreateHumanUser(ctx)
+ Tester.Client.UserV2.VerifyEmail(ctx, &user.VerifyEmailRequest{
+ UserId: userResp.GetUserId(),
+ VerificationCode: userResp.GetEmailCode(),
+ })
+ Tester.Client.UserV2.VerifyPhone(ctx, &user.VerifyPhoneRequest{
+ UserId: userResp.GetUserId(),
+ VerificationCode: userResp.GetPhoneCode(),
+ })
+ Tester.SetUserPassword(ctx, userResp.GetUserId(), integration.UserPassword, false)
+ Tester.RegisterUserPasskey(ctx, userResp.GetUserId())
+ return userResp
+}
+
+func createDeactivatedUser(ctx context.Context) *user.AddHumanUserResponse {
+ userResp := Tester.CreateHumanUser(ctx)
+ _, err := Tester.Client.UserV2.DeactivateUser(ctx, &user.DeactivateUserRequest{UserId: userResp.GetUserId()})
+ logging.OnError(err).Fatal("deactivate human user")
+ return userResp
+}
+
+func createLockedUser(ctx context.Context) *user.AddHumanUserResponse {
+ userResp := Tester.CreateHumanUser(ctx)
+ _, err := Tester.Client.UserV2.LockUser(ctx, &user.LockUserRequest{UserId: userResp.GetUserId()})
+ logging.OnError(err).Fatal("lock human user")
+ return userResp
+}
+
+func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, window time.Duration, metadata map[string][]byte, userAgent *session.UserAgent, expirationWindow time.Duration, userID string, factors ...wantFactor) *session.Session {
+ t.Helper()
+ require.NotEmpty(t, id)
+ require.NotEmpty(t, token)
+
+ resp, err := Client.GetSession(CTX, &session.GetSessionRequest{
+ SessionId: id,
+ SessionToken: &token,
+ })
+ require.NoError(t, err)
+ s := resp.GetSession()
+
+ assert.Equal(t, id, s.GetId())
+ assert.WithinRange(t, s.GetCreationDate().AsTime(), time.Now().Add(-window), time.Now().Add(window))
+ assert.WithinRange(t, s.GetChangeDate().AsTime(), time.Now().Add(-window), time.Now().Add(window))
+ assert.Equal(t, sequence, s.GetSequence())
+ assert.Equal(t, metadata, s.GetMetadata())
+
+ if !proto.Equal(userAgent, s.GetUserAgent()) {
+ t.Errorf("user agent =\n%v\nwant\n%v", s.GetUserAgent(), userAgent)
+ }
+ if expirationWindow == 0 {
+ assert.Nil(t, s.GetExpirationDate())
+ } else {
+ assert.WithinRange(t, s.GetExpirationDate().AsTime(), time.Now().Add(-expirationWindow), time.Now().Add(expirationWindow))
+ }
+
+ verifyFactors(t, s.GetFactors(), window, userID, factors)
+ return s
+}
+
+type wantFactor int
+
+const (
+ wantUserFactor wantFactor = iota
+ wantPasswordFactor
+ wantWebAuthNFactor
+ wantWebAuthNFactorUserVerified
+ wantTOTPFactor
+ wantIntentFactor
+ wantOTPSMSFactor
+ wantOTPEmailFactor
+)
+
+func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration, userID string, want []wantFactor) {
+ for _, w := range want {
+ switch w {
+ case wantUserFactor:
+ uf := factors.GetUser()
+ assert.NotNil(t, uf)
+ assert.WithinRange(t, uf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
+ assert.Equal(t, userID, uf.GetId())
+ case wantPasswordFactor:
+ pf := factors.GetPassword()
+ assert.NotNil(t, pf)
+ assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
+ case wantWebAuthNFactor:
+ pf := factors.GetWebAuthN()
+ assert.NotNil(t, pf)
+ assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
+ assert.False(t, pf.GetUserVerified())
+ case wantWebAuthNFactorUserVerified:
+ pf := factors.GetWebAuthN()
+ assert.NotNil(t, pf)
+ assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
+ assert.True(t, pf.GetUserVerified())
+ case wantTOTPFactor:
+ pf := factors.GetTotp()
+ assert.NotNil(t, pf)
+ assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
+ case wantIntentFactor:
+ pf := factors.GetIntent()
+ assert.NotNil(t, pf)
+ assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
+ case wantOTPSMSFactor:
+ pf := factors.GetOtpSms()
+ assert.NotNil(t, pf)
+ assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
+ case wantOTPEmailFactor:
+ pf := factors.GetOtpEmail()
+ assert.NotNil(t, pf)
+ assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
+ }
+ }
+}
+
+func TestServer_CreateSession(t *testing.T) {
+ tests := []struct {
+ name string
+ req *session.CreateSessionRequest
+ want *session.CreateSessionResponse
+ wantErr bool
+ wantFactors []wantFactor
+ wantUserAgent *session.UserAgent
+ wantExpirationWindow time.Duration
+ }{
+ {
+ name: "empty session",
+ req: &session.CreateSessionRequest{
+ Metadata: map[string][]byte{"foo": []byte("bar")},
+ },
+ want: &session.CreateSessionResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ },
+ },
+ {
+ name: "user agent",
+ req: &session.CreateSessionRequest{
+ Metadata: map[string][]byte{"foo": []byte("bar")},
+ UserAgent: &session.UserAgent{
+ FingerprintId: gu.Ptr("fingerPrintID"),
+ Ip: gu.Ptr("1.2.3.4"),
+ Description: gu.Ptr("Description"),
+ Header: map[string]*session.UserAgent_HeaderValues{
+ "foo": {Values: []string{"foo", "bar"}},
+ },
+ },
+ },
+ want: &session.CreateSessionResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ },
+ wantUserAgent: &session.UserAgent{
+ FingerprintId: gu.Ptr("fingerPrintID"),
+ Ip: gu.Ptr("1.2.3.4"),
+ Description: gu.Ptr("Description"),
+ Header: map[string]*session.UserAgent_HeaderValues{
+ "foo": {Values: []string{"foo", "bar"}},
+ },
+ },
+ },
+ {
+ name: "negative lifetime",
+ req: &session.CreateSessionRequest{
+ Metadata: map[string][]byte{"foo": []byte("bar")},
+ Lifetime: durationpb.New(-5 * time.Minute),
+ },
+ wantErr: true,
+ },
+ {
+ name: "lifetime",
+ req: &session.CreateSessionRequest{
+ Metadata: map[string][]byte{"foo": []byte("bar")},
+ Lifetime: durationpb.New(5 * time.Minute),
+ },
+ want: &session.CreateSessionResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ },
+ wantExpirationWindow: 5 * time.Minute,
+ },
+ {
+ name: "with user",
+ req: &session.CreateSessionRequest{
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: User.GetUserId(),
+ },
+ },
+ },
+ Metadata: map[string][]byte{"foo": []byte("bar")},
+ },
+ want: &session.CreateSessionResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ },
+ wantFactors: []wantFactor{wantUserFactor},
+ },
+ {
+ name: "deactivated user",
+ req: &session.CreateSessionRequest{
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: DeactivatedUser.GetUserId(),
+ },
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "locked user",
+ req: &session.CreateSessionRequest{
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: LockedUser.GetUserId(),
+ },
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "password without user error",
+ req: &session.CreateSessionRequest{
+ Checks: &session.Checks{
+ Password: &session.CheckPassword{
+ Password: "Difficult",
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "passkey without user error",
+ req: &session.CreateSessionRequest{
+ Challenges: &session.RequestChallenges{
+ WebAuthN: &session.RequestChallenges_WebAuthN{
+ Domain: Tester.Config.ExternalDomain,
+ UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED,
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "passkey without domain (not registered) error",
+ req: &session.CreateSessionRequest{
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: User.GetUserId(),
+ },
+ },
+ },
+ Challenges: &session.RequestChallenges{
+ WebAuthN: &session.RequestChallenges_WebAuthN{
+ UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED,
+ },
+ },
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.CreateSession(CTX, tt.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ integration.AssertDetails(t, tt.want, got)
+
+ verifyCurrentSession(t, got.GetSessionId(), got.GetSessionToken(), got.GetDetails().GetSequence(), time.Minute, tt.req.GetMetadata(), tt.wantUserAgent, tt.wantExpirationWindow, User.GetUserId(), tt.wantFactors...)
+ })
+ }
+}
+
+func TestServer_CreateSession_lock_user(t *testing.T) {
+ // create a separate org so we don't interfere with any other test
+ org := Tester.CreateOrganization(IAMOwnerCTX,
+ fmt.Sprintf("TestServer_CreateSession_lock_user_%d", time.Now().UnixNano()),
+ fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()),
+ )
+ userID := org.CreatedAdmins[0].GetUserId()
+ Tester.SetUserPassword(IAMOwnerCTX, userID, integration.UserPassword, false)
+
+ // enable password lockout
+ maxAttempts := 2
+ ctxOrg := metadata.AppendToOutgoingContext(IAMOwnerCTX, "x-zitadel-orgid", org.GetOrganizationId())
+ _, err := Tester.Client.Mgmt.AddCustomLockoutPolicy(ctxOrg, &mgmt.AddCustomLockoutPolicyRequest{
+ MaxPasswordAttempts: uint32(maxAttempts),
+ })
+ require.NoError(t, err)
+
+ for i := 0; i <= maxAttempts; i++ {
+ _, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: userID,
+ },
+ },
+ Password: &session.CheckPassword{
+ Password: "invalid",
+ },
+ },
+ })
+ assert.Error(t, err)
+ statusCode := status.Code(err)
+ expectedCode := codes.InvalidArgument
+ // as soon as we hit the limit the user is locked and following request will
+ // already deny any check with a precondition failed since the user is locked
+ if i >= maxAttempts {
+ expectedCode = codes.FailedPrecondition
+ }
+ assert.Equal(t, expectedCode, statusCode)
+ }
+}
+
+func TestServer_CreateSession_webauthn(t *testing.T) {
+ // create new session with user and request the webauthn challenge
+ createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: User.GetUserId(),
+ },
+ },
+ },
+ Challenges: &session.RequestChallenges{
+ WebAuthN: &session.RequestChallenges_WebAuthN{
+ Domain: Tester.Config.ExternalDomain,
+ UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED,
+ },
+ },
+ })
+ require.NoError(t, err)
+ verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId())
+
+ assertionData, err := Tester.WebAuthN.CreateAssertionResponse(createResp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true)
+ require.NoError(t, err)
+
+ // update the session with webauthn assertion data
+ updateResp, err := Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Checks: &session.Checks{
+ WebAuthN: &session.CheckWebAuthN{
+ CredentialAssertionData: assertionData,
+ },
+ },
+ })
+ require.NoError(t, err)
+ verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactorUserVerified)
+}
+
+func TestServer_CreateSession_successfulIntent(t *testing.T) {
+ idpID := Tester.AddGenericOAuthProvider(t, CTX)
+ createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: User.GetUserId(),
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+ verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId())
+
+ intentID, token, _, _ := Tester.CreateSuccessfulOAuthIntent(t, CTX, idpID, User.GetUserId(), "id")
+ updateResp, err := Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Checks: &session.Checks{
+ IdpIntent: &session.CheckIDPIntent{
+ IdpIntentId: intentID,
+ IdpIntentToken: token,
+ },
+ },
+ })
+ require.NoError(t, err)
+ verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantIntentFactor)
+}
+
+func TestServer_CreateSession_successfulIntent_instant(t *testing.T) {
+ idpID := Tester.AddGenericOAuthProvider(t, CTX)
+
+ intentID, token, _, _ := Tester.CreateSuccessfulOAuthIntent(t, CTX, idpID, User.GetUserId(), "id")
+ createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: User.GetUserId(),
+ },
+ },
+ IdpIntent: &session.CheckIDPIntent{
+ IdpIntentId: intentID,
+ IdpIntentToken: token,
+ },
+ },
+ })
+ require.NoError(t, err)
+ verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantIntentFactor)
+}
+
+func TestServer_CreateSession_successfulIntentUnknownUserID(t *testing.T) {
+ idpID := Tester.AddGenericOAuthProvider(t, CTX)
+
+ // successful intent without known / linked user
+ idpUserID := "id"
+ intentID, token, _, _ := Tester.CreateSuccessfulOAuthIntent(t, CTX, idpID, "", idpUserID)
+
+ // link the user (with info from intent)
+ Tester.CreateUserIDPlink(CTX, User.GetUserId(), idpUserID, idpID, User.GetUserId())
+
+ // session with intent check must now succeed
+ createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: User.GetUserId(),
+ },
+ },
+ IdpIntent: &session.CheckIDPIntent{
+ IdpIntentId: intentID,
+ IdpIntentToken: token,
+ },
+ },
+ })
+ require.NoError(t, err)
+ verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantIntentFactor)
+}
+
+func TestServer_CreateSession_startedIntentFalseToken(t *testing.T) {
+ idpID := Tester.AddGenericOAuthProvider(t, CTX)
+
+ createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: User.GetUserId(),
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+ verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId())
+
+ intentID := Tester.CreateIntent(t, CTX, idpID)
+ _, err = Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Checks: &session.Checks{
+ IdpIntent: &session.CheckIDPIntent{
+ IdpIntentId: intentID,
+ IdpIntentToken: "false",
+ },
+ },
+ })
+ require.Error(t, err)
+}
+
+func registerTOTP(ctx context.Context, t *testing.T, userID string) (secret string) {
+ resp, err := Tester.Client.UserV2.RegisterTOTP(ctx, &user.RegisterTOTPRequest{
+ UserId: userID,
+ })
+ require.NoError(t, err)
+ secret = resp.GetSecret()
+ code, err := totp.GenerateCode(secret, time.Now())
+ require.NoError(t, err)
+
+ _, err = Tester.Client.UserV2.VerifyTOTPRegistration(ctx, &user.VerifyTOTPRegistrationRequest{
+ UserId: userID,
+ Code: code,
+ })
+ require.NoError(t, err)
+ return secret
+}
+
+func registerOTPSMS(ctx context.Context, t *testing.T, userID string) {
+ _, err := Tester.Client.UserV2.AddOTPSMS(ctx, &user.AddOTPSMSRequest{
+ UserId: userID,
+ })
+ require.NoError(t, err)
+}
+
+func registerOTPEmail(ctx context.Context, t *testing.T, userID string) {
+ _, err := Tester.Client.UserV2.AddOTPEmail(ctx, &user.AddOTPEmailRequest{
+ UserId: userID,
+ })
+ require.NoError(t, err)
+}
+
+func TestServer_SetSession_flow_totp(t *testing.T) {
+ userExisting := createFullUser(CTX)
+
+ // create new, empty session
+ createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{})
+ require.NoError(t, err)
+ sessionToken := createResp.GetSessionToken()
+ verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, "")
+
+ t.Run("check user", func(t *testing.T) {
+ resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: userExisting.GetUserId(),
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+ sessionToken = resp.GetSessionToken()
+ verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, userExisting.GetUserId(), wantUserFactor)
+ })
+
+ t.Run("check webauthn, user verified (passkey)", func(t *testing.T) {
+ resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Challenges: &session.RequestChallenges{
+ WebAuthN: &session.RequestChallenges_WebAuthN{
+ Domain: Tester.Config.ExternalDomain,
+ UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED,
+ },
+ },
+ })
+ require.NoError(t, err)
+ verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, userExisting.GetUserId())
+ sessionToken = resp.GetSessionToken()
+
+ assertionData, err := Tester.WebAuthN.CreateAssertionResponse(resp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true)
+ require.NoError(t, err)
+
+ resp, err = Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Checks: &session.Checks{
+ WebAuthN: &session.CheckWebAuthN{
+ CredentialAssertionData: assertionData,
+ },
+ },
+ })
+ require.NoError(t, err)
+ sessionToken = resp.GetSessionToken()
+ verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, userExisting.GetUserId(), wantUserFactor, wantWebAuthNFactorUserVerified)
+ })
+
+ userAuthCtx := Tester.WithAuthorizationToken(CTX, sessionToken)
+ Tester.RegisterUserU2F(userAuthCtx, userExisting.GetUserId())
+ totpSecret := registerTOTP(userAuthCtx, t, userExisting.GetUserId())
+ registerOTPSMS(userAuthCtx, t, userExisting.GetUserId())
+ registerOTPEmail(userAuthCtx, t, userExisting.GetUserId())
+
+ t.Run("check TOTP", func(t *testing.T) {
+ code, err := totp.GenerateCode(totpSecret, time.Now())
+ require.NoError(t, err)
+ resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Checks: &session.Checks{
+ Totp: &session.CheckTOTP{
+ Code: code,
+ },
+ },
+ })
+ require.NoError(t, err)
+ sessionToken = resp.GetSessionToken()
+ verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, userExisting.GetUserId(), wantUserFactor, wantTOTPFactor)
+ })
+
+ userImport := Tester.CreateHumanUserWithTOTP(CTX, totpSecret)
+ createRespImport, err := Client.CreateSession(CTX, &session.CreateSessionRequest{})
+ require.NoError(t, err)
+ sessionTokenImport := createRespImport.GetSessionToken()
+ verifyCurrentSession(t, createRespImport.GetSessionId(), sessionTokenImport, createRespImport.GetDetails().GetSequence(), time.Minute, nil, nil, 0, "")
+
+ t.Run("check user", func(t *testing.T) {
+ resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createRespImport.GetSessionId(),
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: userImport.GetUserId(),
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+ sessionTokenImport = resp.GetSessionToken()
+ verifyCurrentSession(t, createRespImport.GetSessionId(), sessionTokenImport, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, userImport.GetUserId(), wantUserFactor)
+ })
+ t.Run("check TOTP", func(t *testing.T) {
+ code, err := totp.GenerateCode(totpSecret, time.Now())
+ require.NoError(t, err)
+ resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createRespImport.GetSessionId(),
+ Checks: &session.Checks{
+ Totp: &session.CheckTOTP{
+ Code: code,
+ },
+ },
+ })
+ require.NoError(t, err)
+ sessionTokenImport = resp.GetSessionToken()
+ verifyCurrentSession(t, createRespImport.GetSessionId(), sessionTokenImport, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, userImport.GetUserId(), wantUserFactor, wantTOTPFactor)
+ })
+}
+
+func TestServer_SetSession_flow(t *testing.T) {
+ // create new, empty session
+ createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{})
+ require.NoError(t, err)
+ sessionToken := createResp.GetSessionToken()
+ verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId())
+
+ t.Run("check user", func(t *testing.T) {
+ resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: User.GetUserId(),
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+ sessionToken = resp.GetSessionToken()
+ verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor)
+ })
+
+ t.Run("check webauthn, user verified (passkey)", func(t *testing.T) {
+ resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Challenges: &session.RequestChallenges{
+ WebAuthN: &session.RequestChallenges_WebAuthN{
+ Domain: Tester.Config.ExternalDomain,
+ UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED,
+ },
+ },
+ })
+ require.NoError(t, err)
+ verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId())
+ sessionToken = resp.GetSessionToken()
+
+ assertionData, err := Tester.WebAuthN.CreateAssertionResponse(resp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true)
+ require.NoError(t, err)
+
+ resp, err = Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Checks: &session.Checks{
+ WebAuthN: &session.CheckWebAuthN{
+ CredentialAssertionData: assertionData,
+ },
+ },
+ })
+ require.NoError(t, err)
+ sessionToken = resp.GetSessionToken()
+ verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactorUserVerified)
+ })
+
+ userAuthCtx := Tester.WithAuthorizationToken(CTX, sessionToken)
+ Tester.RegisterUserU2F(userAuthCtx, User.GetUserId())
+ totpSecret := registerTOTP(userAuthCtx, t, User.GetUserId())
+ registerOTPSMS(userAuthCtx, t, User.GetUserId())
+ registerOTPEmail(userAuthCtx, t, User.GetUserId())
+
+ t.Run("check webauthn, user not verified (U2F)", func(t *testing.T) {
+
+ for _, userVerificationRequirement := range []session.UserVerificationRequirement{
+ session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_PREFERRED,
+ session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_DISCOURAGED,
+ } {
+ t.Run(userVerificationRequirement.String(), func(t *testing.T) {
+ resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Challenges: &session.RequestChallenges{
+ WebAuthN: &session.RequestChallenges_WebAuthN{
+ Domain: Tester.Config.ExternalDomain,
+ UserVerificationRequirement: userVerificationRequirement,
+ },
+ },
+ })
+ require.NoError(t, err)
+ verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId())
+ sessionToken = resp.GetSessionToken()
+
+ assertionData, err := Tester.WebAuthN.CreateAssertionResponse(resp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), false)
+ require.NoError(t, err)
+
+ resp, err = Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Checks: &session.Checks{
+ WebAuthN: &session.CheckWebAuthN{
+ CredentialAssertionData: assertionData,
+ },
+ },
+ })
+ require.NoError(t, err)
+ sessionToken = resp.GetSessionToken()
+ verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactor)
+ })
+ }
+ })
+
+ t.Run("check TOTP", func(t *testing.T) {
+ code, err := totp.GenerateCode(totpSecret, time.Now())
+ require.NoError(t, err)
+ resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Checks: &session.Checks{
+ Totp: &session.CheckTOTP{
+ Code: code,
+ },
+ },
+ })
+ require.NoError(t, err)
+ sessionToken = resp.GetSessionToken()
+ verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactor, wantTOTPFactor)
+ })
+
+ t.Run("check OTP SMS", func(t *testing.T) {
+ resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Challenges: &session.RequestChallenges{
+ OtpSms: &session.RequestChallenges_OTPSMS{ReturnCode: true},
+ },
+ })
+ require.NoError(t, err)
+ verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId())
+ sessionToken = resp.GetSessionToken()
+
+ otp := resp.GetChallenges().GetOtpSms()
+ require.NotEmpty(t, otp)
+
+ resp, err = Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Checks: &session.Checks{
+ OtpSms: &session.CheckOTP{
+ Code: otp,
+ },
+ },
+ })
+ require.NoError(t, err)
+ sessionToken = resp.GetSessionToken()
+ verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactor, wantOTPSMSFactor)
+ })
+
+ t.Run("check OTP Email", func(t *testing.T) {
+ resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Challenges: &session.RequestChallenges{
+ OtpEmail: &session.RequestChallenges_OTPEmail{
+ DeliveryType: &session.RequestChallenges_OTPEmail_ReturnCode_{},
+ },
+ },
+ })
+ require.NoError(t, err)
+ verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId())
+ sessionToken = resp.GetSessionToken()
+
+ otp := resp.GetChallenges().GetOtpEmail()
+ require.NotEmpty(t, otp)
+
+ resp, err = Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Checks: &session.Checks{
+ OtpEmail: &session.CheckOTP{
+ Code: otp,
+ },
+ },
+ })
+ require.NoError(t, err)
+ sessionToken = resp.GetSessionToken()
+ verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactor, wantOTPEmailFactor)
+ })
+}
+
+func TestServer_SetSession_expired(t *testing.T) {
+ createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
+ Lifetime: durationpb.New(20 * time.Second),
+ })
+ require.NoError(t, err)
+
+ // test session token works
+ _, err = Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Lifetime: durationpb.New(20 * time.Second),
+ })
+ require.NoError(t, err)
+
+ // ensure session expires and does not work anymore
+ time.Sleep(20 * time.Second)
+ _, err = Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ Lifetime: durationpb.New(20 * time.Second),
+ })
+ require.Error(t, err)
+}
+
+func TestServer_DeleteSession_token(t *testing.T) {
+ createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{})
+ require.NoError(t, err)
+
+ _, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ SessionToken: gu.Ptr("invalid"),
+ })
+ require.Error(t, err)
+
+ _, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ SessionToken: gu.Ptr(createResp.GetSessionToken()),
+ })
+ require.NoError(t, err)
+}
+
+func TestServer_DeleteSession_own_session(t *testing.T) {
+ // create two users for the test and a session each to get tokens for authorization
+ user1 := Tester.CreateHumanUser(CTX)
+ Tester.SetUserPassword(CTX, user1.GetUserId(), integration.UserPassword, false)
+ _, token1, _, _ := Tester.CreatePasswordSession(t, CTX, user1.GetUserId(), integration.UserPassword)
+
+ user2 := Tester.CreateHumanUser(CTX)
+ Tester.SetUserPassword(CTX, user2.GetUserId(), integration.UserPassword, false)
+ _, token2, _, _ := Tester.CreatePasswordSession(t, CTX, user2.GetUserId(), integration.UserPassword)
+
+ // create a new session for the first user
+ createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: user1.GetUserId(),
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ // delete the new (user1) session must not be possible with user (has no permission)
+ _, err = Client.DeleteSession(Tester.WithAuthorizationToken(context.Background(), token2), &session.DeleteSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ })
+ require.Error(t, err)
+
+ // delete the new (user1) session by themselves
+ _, err = Client.DeleteSession(Tester.WithAuthorizationToken(context.Background(), token1), &session.DeleteSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ })
+ require.NoError(t, err)
+}
+
+func TestServer_DeleteSession_with_permission(t *testing.T) {
+ createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
+ Checks: &session.Checks{
+ User: &session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: User.GetUserId(),
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ // delete the new session by ORG_OWNER
+ _, err = Client.DeleteSession(Tester.WithAuthorization(context.Background(), integration.OrgOwner), &session.DeleteSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ })
+ require.NoError(t, err)
+}
+
+func Test_ZITADEL_API_missing_authentication(t *testing.T) {
+ // create new, empty session
+ createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{})
+ require.NoError(t, err)
+
+ ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", createResp.GetSessionToken()))
+ sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()})
+ require.Error(t, err)
+ require.Nil(t, sessionResp)
+}
+
+func Test_ZITADEL_API_success(t *testing.T) {
+ id, token, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, User.GetUserId())
+
+ ctx := Tester.WithAuthorizationToken(context.Background(), token)
+ sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
+ require.NoError(t, err)
+
+ webAuthN := sessionResp.GetSession().GetFactors().GetWebAuthN()
+ require.NotNil(t, id, webAuthN.GetVerifiedAt().AsTime())
+ require.True(t, webAuthN.GetUserVerified())
+}
+
+func Test_ZITADEL_API_session_not_found(t *testing.T) {
+ id, token, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, User.GetUserId())
+
+ // test session token works
+ ctx := Tester.WithAuthorizationToken(context.Background(), token)
+ _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
+ require.NoError(t, err)
+
+ //terminate the session and test it does not work anymore
+ _, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{
+ SessionId: id,
+ SessionToken: gu.Ptr(token),
+ })
+ require.NoError(t, err)
+ ctx = Tester.WithAuthorizationToken(context.Background(), token)
+ _, err = Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
+ require.Error(t, err)
+}
+
+func Test_ZITADEL_API_session_expired(t *testing.T) {
+ id, token, _, _ := Tester.CreateVerifiedWebAuthNSessionWithLifetime(t, CTX, User.GetUserId(), 20*time.Second)
+
+ // test session token works
+ ctx := Tester.WithAuthorizationToken(context.Background(), token)
+ _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
+ require.NoError(t, err)
+
+ // ensure session expires and does not work anymore
+ time.Sleep(20 * time.Second)
+ sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id})
+ require.Error(t, err)
+ require.Nil(t, sessionResp)
+}
diff --git a/internal/api/grpc/session/v2beta/session_test.go b/internal/api/grpc/session/v2beta/session_test.go
new file mode 100644
index 0000000000..c088b5b886
--- /dev/null
+++ b/internal/api/grpc/session/v2beta/session_test.go
@@ -0,0 +1,739 @@
+package session
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/query"
+ "github.com/zitadel/zitadel/internal/zerrors"
+ objpb "github.com/zitadel/zitadel/pkg/grpc/object"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
+)
+
+var (
+ creationDate = time.Date(2023, 10, 10, 14, 15, 0, 0, time.UTC)
+)
+
+func Test_sessionsToPb(t *testing.T) {
+ now := time.Now()
+ past := now.Add(-time.Hour)
+
+ sessions := []*query.Session{
+ { // no factor, with user agent and expiration
+ ID: "999",
+ CreationDate: now,
+ ChangeDate: now,
+ Sequence: 123,
+ State: domain.SessionStateActive,
+ ResourceOwner: "me",
+ Creator: "he",
+ Metadata: map[string][]byte{"hello": []byte("world")},
+ UserAgent: domain.UserAgent{
+ FingerprintID: gu.Ptr("fingerprintID"),
+ Description: gu.Ptr("description"),
+ IP: net.IPv4(1, 2, 3, 4),
+ Header: http.Header{"foo": []string{"foo", "bar"}},
+ },
+ Expiration: now,
+ },
+ { // user factor
+ ID: "999",
+ CreationDate: now,
+ ChangeDate: now,
+ Sequence: 123,
+ State: domain.SessionStateActive,
+ ResourceOwner: "me",
+ Creator: "he",
+ UserFactor: query.SessionUserFactor{
+ UserID: "345",
+ UserCheckedAt: past,
+ LoginName: "donald",
+ DisplayName: "donald duck",
+ ResourceOwner: "org1",
+ },
+ Metadata: map[string][]byte{"hello": []byte("world")},
+ },
+ { // password factor
+ ID: "999",
+ CreationDate: now,
+ ChangeDate: now,
+ Sequence: 123,
+ State: domain.SessionStateActive,
+ ResourceOwner: "me",
+ Creator: "he",
+ UserFactor: query.SessionUserFactor{
+ UserID: "345",
+ UserCheckedAt: past,
+ LoginName: "donald",
+ DisplayName: "donald duck",
+ ResourceOwner: "org1",
+ },
+ PasswordFactor: query.SessionPasswordFactor{
+ PasswordCheckedAt: past,
+ },
+ Metadata: map[string][]byte{"hello": []byte("world")},
+ },
+ { // webAuthN factor
+ ID: "999",
+ CreationDate: now,
+ ChangeDate: now,
+ Sequence: 123,
+ State: domain.SessionStateActive,
+ ResourceOwner: "me",
+ Creator: "he",
+ UserFactor: query.SessionUserFactor{
+ UserID: "345",
+ UserCheckedAt: past,
+ LoginName: "donald",
+ DisplayName: "donald duck",
+ ResourceOwner: "org1",
+ },
+ WebAuthNFactor: query.SessionWebAuthNFactor{
+ WebAuthNCheckedAt: past,
+ UserVerified: true,
+ },
+ Metadata: map[string][]byte{"hello": []byte("world")},
+ },
+ { // totp factor
+ ID: "999",
+ CreationDate: now,
+ ChangeDate: now,
+ Sequence: 123,
+ State: domain.SessionStateActive,
+ ResourceOwner: "me",
+ Creator: "he",
+ UserFactor: query.SessionUserFactor{
+ UserID: "345",
+ UserCheckedAt: past,
+ LoginName: "donald",
+ DisplayName: "donald duck",
+ ResourceOwner: "org1",
+ },
+ TOTPFactor: query.SessionTOTPFactor{
+ TOTPCheckedAt: past,
+ },
+ Metadata: map[string][]byte{"hello": []byte("world")},
+ },
+ }
+
+ want := []*session.Session{
+ { // no factor, with user agent and expiration
+ Id: "999",
+ CreationDate: timestamppb.New(now),
+ ChangeDate: timestamppb.New(now),
+ Sequence: 123,
+ Factors: nil,
+ Metadata: map[string][]byte{"hello": []byte("world")},
+ UserAgent: &session.UserAgent{
+ FingerprintId: gu.Ptr("fingerprintID"),
+ Description: gu.Ptr("description"),
+ Ip: gu.Ptr("1.2.3.4"),
+ Header: map[string]*session.UserAgent_HeaderValues{
+ "foo": {Values: []string{"foo", "bar"}},
+ },
+ },
+ ExpirationDate: timestamppb.New(now),
+ },
+ { // user factor
+ Id: "999",
+ CreationDate: timestamppb.New(now),
+ ChangeDate: timestamppb.New(now),
+ Sequence: 123,
+ Factors: &session.Factors{
+ User: &session.UserFactor{
+ VerifiedAt: timestamppb.New(past),
+ Id: "345",
+ LoginName: "donald",
+ DisplayName: "donald duck",
+ OrganizationId: "org1",
+ },
+ },
+ Metadata: map[string][]byte{"hello": []byte("world")},
+ },
+ { // password factor
+ Id: "999",
+ CreationDate: timestamppb.New(now),
+ ChangeDate: timestamppb.New(now),
+ Sequence: 123,
+ Factors: &session.Factors{
+ User: &session.UserFactor{
+ VerifiedAt: timestamppb.New(past),
+ Id: "345",
+ LoginName: "donald",
+ DisplayName: "donald duck",
+ OrganizationId: "org1",
+ },
+ Password: &session.PasswordFactor{
+ VerifiedAt: timestamppb.New(past),
+ },
+ },
+ Metadata: map[string][]byte{"hello": []byte("world")},
+ },
+ { // webAuthN factor
+ Id: "999",
+ CreationDate: timestamppb.New(now),
+ ChangeDate: timestamppb.New(now),
+ Sequence: 123,
+ Factors: &session.Factors{
+ User: &session.UserFactor{
+ VerifiedAt: timestamppb.New(past),
+ Id: "345",
+ LoginName: "donald",
+ DisplayName: "donald duck",
+ OrganizationId: "org1",
+ },
+ WebAuthN: &session.WebAuthNFactor{
+ VerifiedAt: timestamppb.New(past),
+ UserVerified: true,
+ },
+ },
+ Metadata: map[string][]byte{"hello": []byte("world")},
+ },
+ { // totp factor
+ Id: "999",
+ CreationDate: timestamppb.New(now),
+ ChangeDate: timestamppb.New(now),
+ Sequence: 123,
+ Factors: &session.Factors{
+ User: &session.UserFactor{
+ VerifiedAt: timestamppb.New(past),
+ Id: "345",
+ LoginName: "donald",
+ DisplayName: "donald duck",
+ OrganizationId: "org1",
+ },
+ Totp: &session.TOTPFactor{
+ VerifiedAt: timestamppb.New(past),
+ },
+ },
+ Metadata: map[string][]byte{"hello": []byte("world")},
+ },
+ }
+
+ out := sessionsToPb(sessions)
+ require.Len(t, out, len(want))
+
+ for i, got := range out {
+ if !proto.Equal(got, want[i]) {
+ t.Errorf("session %d got:\n%v\nwant:\n%v", i, got, want[i])
+ }
+ }
+}
+
+func Test_userAgentToPb(t *testing.T) {
+ type args struct {
+ ua domain.UserAgent
+ }
+ tests := []struct {
+ name string
+ args args
+ want *session.UserAgent
+ }{
+ {
+ name: "empty",
+ args: args{domain.UserAgent{}},
+ },
+ {
+ name: "fingerprint id and description",
+ args: args{domain.UserAgent{
+ FingerprintID: gu.Ptr("fingerPrintID"),
+ Description: gu.Ptr("description"),
+ }},
+ want: &session.UserAgent{
+ FingerprintId: gu.Ptr("fingerPrintID"),
+ Description: gu.Ptr("description"),
+ },
+ },
+ {
+ name: "with ip",
+ args: args{domain.UserAgent{
+ FingerprintID: gu.Ptr("fingerPrintID"),
+ Description: gu.Ptr("description"),
+ IP: net.IPv4(1, 2, 3, 4),
+ }},
+ want: &session.UserAgent{
+ FingerprintId: gu.Ptr("fingerPrintID"),
+ Description: gu.Ptr("description"),
+ Ip: gu.Ptr("1.2.3.4"),
+ },
+ },
+ {
+ name: "with header",
+ args: args{domain.UserAgent{
+ FingerprintID: gu.Ptr("fingerPrintID"),
+ Description: gu.Ptr("description"),
+ Header: http.Header{
+ "foo": []string{"foo", "bar"},
+ "hello": []string{"world"},
+ },
+ }},
+ want: &session.UserAgent{
+ FingerprintId: gu.Ptr("fingerPrintID"),
+ Description: gu.Ptr("description"),
+ Header: map[string]*session.UserAgent_HeaderValues{
+ "foo": {Values: []string{"foo", "bar"}},
+ "hello": {Values: []string{"world"}},
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := userAgentToPb(tt.args.ua)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func mustNewTextQuery(t testing.TB, column query.Column, value string, compare query.TextComparison) query.SearchQuery {
+ q, err := query.NewTextQuery(column, value, compare)
+ require.NoError(t, err)
+ return q
+}
+
+func mustNewListQuery(t testing.TB, column query.Column, list []any, compare query.ListComparison) query.SearchQuery {
+ q, err := query.NewListQuery(query.SessionColumnID, list, compare)
+ require.NoError(t, err)
+ return q
+}
+
+func mustNewTimestampQuery(t testing.TB, column query.Column, ts time.Time, compare query.TimestampComparison) query.SearchQuery {
+ q, err := query.NewTimestampQuery(column, ts, compare)
+ require.NoError(t, err)
+ return q
+}
+
+func Test_listSessionsRequestToQuery(t *testing.T) {
+ type args struct {
+ ctx context.Context
+ req *session.ListSessionsRequest
+ }
+
+ tests := []struct {
+ name string
+ args args
+ want *query.SessionsSearchQueries
+ wantErr error
+ }{
+ {
+ name: "default request",
+ args: args{
+ ctx: authz.NewMockContext("123", "456", "789"),
+ req: &session.ListSessionsRequest{},
+ },
+ want: &query.SessionsSearchQueries{
+ SearchRequest: query.SearchRequest{
+ Offset: 0,
+ Limit: 0,
+ Asc: false,
+ },
+ Queries: []query.SearchQuery{
+ mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
+ },
+ },
+ },
+ {
+ name: "default request with sorting column",
+ args: args{
+ ctx: authz.NewMockContext("123", "456", "789"),
+ req: &session.ListSessionsRequest{
+ SortingColumn: session.SessionFieldName_SESSION_FIELD_NAME_CREATION_DATE,
+ },
+ },
+ want: &query.SessionsSearchQueries{
+ SearchRequest: query.SearchRequest{
+ Offset: 0,
+ Limit: 0,
+ SortingColumn: query.SessionColumnCreationDate,
+ Asc: false,
+ },
+ Queries: []query.SearchQuery{
+ mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
+ },
+ },
+ },
+ {
+ name: "with list query and sessions",
+ args: args{
+ ctx: authz.NewMockContext("123", "456", "789"),
+ req: &session.ListSessionsRequest{
+ Query: &object.ListQuery{
+ Offset: 10,
+ Limit: 20,
+ Asc: true,
+ },
+ Queries: []*session.SearchQuery{
+ {Query: &session.SearchQuery_IdsQuery{
+ IdsQuery: &session.IDsQuery{
+ Ids: []string{"1", "2", "3"},
+ },
+ }},
+ {Query: &session.SearchQuery_IdsQuery{
+ IdsQuery: &session.IDsQuery{
+ Ids: []string{"4", "5", "6"},
+ },
+ }},
+ {Query: &session.SearchQuery_UserIdQuery{
+ UserIdQuery: &session.UserIDQuery{
+ Id: "10",
+ },
+ }},
+ {Query: &session.SearchQuery_CreationDateQuery{
+ CreationDateQuery: &session.CreationDateQuery{
+ CreationDate: timestamppb.New(creationDate),
+ Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER,
+ },
+ }},
+ },
+ },
+ },
+ want: &query.SessionsSearchQueries{
+ SearchRequest: query.SearchRequest{
+ Offset: 10,
+ Limit: 20,
+ Asc: true,
+ },
+ Queries: []query.SearchQuery{
+ mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn),
+ mustNewListQuery(t, query.SessionColumnID, []interface{}{"4", "5", "6"}, query.ListIn),
+ mustNewTextQuery(t, query.SessionColumnUserID, "10", query.TextEquals),
+ mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampGreater),
+ mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
+ },
+ },
+ },
+ {
+ name: "invalid argument error",
+ args: args{
+ ctx: authz.NewMockContext("123", "456", "789"),
+ req: &session.ListSessionsRequest{
+ Query: &object.ListQuery{
+ Offset: 10,
+ Limit: 20,
+ Asc: true,
+ },
+ Queries: []*session.SearchQuery{
+ {Query: &session.SearchQuery_IdsQuery{
+ IdsQuery: &session.IDsQuery{
+ Ids: []string{"1", "2", "3"},
+ },
+ }},
+ {Query: nil},
+ },
+ },
+ },
+ wantErr: zerrors.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"),
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := listSessionsRequestToQuery(tt.args.ctx, tt.args.req)
+ require.ErrorIs(t, err, tt.wantErr)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func Test_sessionQueriesToQuery(t *testing.T) {
+ type args struct {
+ ctx context.Context
+ queries []*session.SearchQuery
+ }
+ tests := []struct {
+ name string
+ args args
+ want []query.SearchQuery
+ wantErr error
+ }{
+ {
+ name: "creator only",
+ args: args{
+ ctx: authz.NewMockContext("123", "456", "789"),
+ },
+ want: []query.SearchQuery{
+ mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
+ },
+ },
+ {
+ name: "invalid argument",
+ args: args{
+ ctx: authz.NewMockContext("123", "456", "789"),
+ queries: []*session.SearchQuery{
+ {Query: nil},
+ },
+ },
+ wantErr: zerrors.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"),
+ },
+ {
+ name: "creator and sessions",
+ args: args{
+ ctx: authz.NewMockContext("123", "456", "789"),
+ queries: []*session.SearchQuery{
+ {Query: &session.SearchQuery_IdsQuery{
+ IdsQuery: &session.IDsQuery{
+ Ids: []string{"1", "2", "3"},
+ },
+ }},
+ {Query: &session.SearchQuery_IdsQuery{
+ IdsQuery: &session.IDsQuery{
+ Ids: []string{"4", "5", "6"},
+ },
+ }},
+ },
+ },
+ want: []query.SearchQuery{
+ mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn),
+ mustNewListQuery(t, query.SessionColumnID, []interface{}{"4", "5", "6"}, query.ListIn),
+ mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := sessionQueriesToQuery(tt.args.ctx, tt.args.queries)
+ require.ErrorIs(t, err, tt.wantErr)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func Test_sessionQueryToQuery(t *testing.T) {
+ type args struct {
+ query *session.SearchQuery
+ }
+ tests := []struct {
+ name string
+ args args
+ want query.SearchQuery
+ wantErr error
+ }{
+ {
+ name: "invalid argument",
+ args: args{&session.SearchQuery{
+ Query: nil,
+ }},
+ wantErr: zerrors.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"),
+ },
+ {
+ name: "ids query",
+ args: args{&session.SearchQuery{
+ Query: &session.SearchQuery_IdsQuery{
+ IdsQuery: &session.IDsQuery{
+ Ids: []string{"1", "2", "3"},
+ },
+ },
+ }},
+ want: mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn),
+ },
+ {
+ name: "user id query",
+ args: args{&session.SearchQuery{
+ Query: &session.SearchQuery_UserIdQuery{
+ UserIdQuery: &session.UserIDQuery{
+ Id: "10",
+ },
+ },
+ }},
+ want: mustNewTextQuery(t, query.SessionColumnUserID, "10", query.TextEquals),
+ },
+ {
+ name: "creation date query",
+ args: args{&session.SearchQuery{
+ Query: &session.SearchQuery_CreationDateQuery{
+ CreationDateQuery: &session.CreationDateQuery{
+ CreationDate: timestamppb.New(creationDate),
+ Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS,
+ },
+ },
+ }},
+ want: mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampLess),
+ },
+ {
+ name: "creation date query with default method",
+ args: args{&session.SearchQuery{
+ Query: &session.SearchQuery_CreationDateQuery{
+ CreationDateQuery: &session.CreationDateQuery{
+ CreationDate: timestamppb.New(creationDate),
+ },
+ },
+ }},
+ want: mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampEquals),
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := sessionQueryToQuery(tt.args.query)
+ require.ErrorIs(t, err, tt.wantErr)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func Test_userCheck(t *testing.T) {
+ type args struct {
+ user *session.CheckUser
+ }
+ tests := []struct {
+ name string
+ args args
+ want userSearch
+ wantErr error
+ }{
+ {
+ name: "nil user",
+ args: args{nil},
+ want: nil,
+ },
+ {
+ name: "by user id",
+ args: args{&session.CheckUser{
+ Search: &session.CheckUser_UserId{
+ UserId: "foo",
+ },
+ }},
+ want: userSearchByID{"foo"},
+ },
+ {
+ name: "by user id",
+ args: args{&session.CheckUser{
+ Search: &session.CheckUser_LoginName{
+ LoginName: "bar",
+ },
+ }},
+ want: userSearchByLoginName{"bar"},
+ },
+ {
+ name: "unimplemented error",
+ args: args{&session.CheckUser{
+ Search: nil,
+ }},
+ wantErr: zerrors.ThrowUnimplementedf(nil, "SESSION-d3b4g0", "user search %T not implemented", nil),
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := userCheck(tt.args.user)
+ require.ErrorIs(t, err, tt.wantErr)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func Test_userVerificationRequirementToDomain(t *testing.T) {
+ type args struct {
+ req session.UserVerificationRequirement
+ }
+ tests := []struct {
+ args args
+ want domain.UserVerificationRequirement
+ }{
+ {
+ args: args{session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_UNSPECIFIED},
+ want: domain.UserVerificationRequirementUnspecified,
+ },
+ {
+ args: args{session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED},
+ want: domain.UserVerificationRequirementRequired,
+ },
+ {
+ args: args{session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_PREFERRED},
+ want: domain.UserVerificationRequirementPreferred,
+ },
+ {
+ args: args{session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_DISCOURAGED},
+ want: domain.UserVerificationRequirementDiscouraged,
+ },
+ {
+ args: args{999},
+ want: domain.UserVerificationRequirementUnspecified,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.args.req.String(), func(t *testing.T) {
+ got := userVerificationRequirementToDomain(tt.args.req)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func Test_userAgentToCommand(t *testing.T) {
+ type args struct {
+ userAgent *session.UserAgent
+ }
+ tests := []struct {
+ name string
+ args args
+ want *domain.UserAgent
+ }{
+ {
+ name: "nil",
+ args: args{nil},
+ want: nil,
+ },
+ {
+ name: "all fields",
+ args: args{&session.UserAgent{
+ FingerprintId: gu.Ptr("fp1"),
+ Ip: gu.Ptr("1.2.3.4"),
+ Description: gu.Ptr("firefox"),
+ Header: map[string]*session.UserAgent_HeaderValues{
+ "hello": {
+ Values: []string{"foo", "bar"},
+ },
+ },
+ }},
+ want: &domain.UserAgent{
+ FingerprintID: gu.Ptr("fp1"),
+ IP: net.ParseIP("1.2.3.4"),
+ Description: gu.Ptr("firefox"),
+ Header: http.Header{
+ "hello": []string{"foo", "bar"},
+ },
+ },
+ },
+ {
+ name: "invalid ip",
+ args: args{&session.UserAgent{
+ FingerprintId: gu.Ptr("fp1"),
+ Ip: gu.Ptr("oops"),
+ Description: gu.Ptr("firefox"),
+ Header: map[string]*session.UserAgent_HeaderValues{
+ "hello": {
+ Values: []string{"foo", "bar"},
+ },
+ },
+ }},
+ want: &domain.UserAgent{
+ FingerprintID: gu.Ptr("fp1"),
+ IP: nil,
+ Description: gu.Ptr("firefox"),
+ Header: http.Header{
+ "hello": []string{"foo", "bar"},
+ },
+ },
+ },
+ {
+ name: "nil fields",
+ args: args{&session.UserAgent{}},
+ want: &domain.UserAgent{},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := userAgentToCommand(tt.args.userAgent)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/internal/api/grpc/settings/v2/server.go b/internal/api/grpc/settings/v2/server.go
index f001549595..0391d01188 100644
--- a/internal/api/grpc/settings/v2/server.go
+++ b/internal/api/grpc/settings/v2/server.go
@@ -10,7 +10,7 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc/server"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/query"
- settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/settings/v2"
)
var _ settings.SettingsServiceServer = (*Server)(nil)
diff --git a/internal/api/grpc/settings/v2/server_integration_test.go b/internal/api/grpc/settings/v2/server_integration_test.go
index 703e8cb971..611194a5ce 100644
--- a/internal/api/grpc/settings/v2/server_integration_test.go
+++ b/internal/api/grpc/settings/v2/server_integration_test.go
@@ -9,7 +9,7 @@ import (
"time"
"github.com/zitadel/zitadel/internal/integration"
- settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/settings/v2"
)
var (
diff --git a/internal/api/grpc/settings/v2/settings.go b/internal/api/grpc/settings/v2/settings.go
index 9b6546645a..3e48ab0c04 100644
--- a/internal/api/grpc/settings/v2/settings.go
+++ b/internal/api/grpc/settings/v2/settings.go
@@ -10,8 +10,8 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/i18n"
"github.com/zitadel/zitadel/internal/query"
- object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta"
+ object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/settings/v2"
)
func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) {
diff --git a/internal/api/grpc/settings/v2/settings_converter.go b/internal/api/grpc/settings/v2/settings_converter.go
index 912df689aa..848ea3e14a 100644
--- a/internal/api/grpc/settings/v2/settings_converter.go
+++ b/internal/api/grpc/settings/v2/settings_converter.go
@@ -8,7 +8,7 @@ import (
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
- settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/settings/v2"
)
func loginSettingsToPb(current *query.LoginPolicy) *settings.LoginSettings {
diff --git a/internal/api/grpc/settings/v2/settings_converter_test.go b/internal/api/grpc/settings/v2/settings_converter_test.go
index 99c60f2628..75785c47b8 100644
--- a/internal/api/grpc/settings/v2/settings_converter_test.go
+++ b/internal/api/grpc/settings/v2/settings_converter_test.go
@@ -16,7 +16,7 @@ import (
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
- settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/settings/v2"
)
var ignoreTypes = []protoreflect.FullName{"google.protobuf.Duration"}
diff --git a/internal/api/grpc/settings/v2/settings_integration_test.go b/internal/api/grpc/settings/v2/settings_integration_test.go
index 3accc0d63f..c9f5f7bd8f 100644
--- a/internal/api/grpc/settings/v2/settings_integration_test.go
+++ b/internal/api/grpc/settings/v2/settings_integration_test.go
@@ -11,8 +11,8 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/integration"
- object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta"
+ object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/settings/v2"
)
func TestServer_GetSecuritySettings(t *testing.T) {
diff --git a/internal/api/grpc/settings/v2beta/server.go b/internal/api/grpc/settings/v2beta/server.go
new file mode 100644
index 0000000000..f001549595
--- /dev/null
+++ b/internal/api/grpc/settings/v2beta/server.go
@@ -0,0 +1,57 @@
+package settings
+
+import (
+ "context"
+
+ "google.golang.org/grpc"
+
+ "github.com/zitadel/zitadel/internal/api/assets"
+ "github.com/zitadel/zitadel/internal/api/authz"
+ "github.com/zitadel/zitadel/internal/api/grpc/server"
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/query"
+ settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta"
+)
+
+var _ settings.SettingsServiceServer = (*Server)(nil)
+
+type Server struct {
+ settings.UnimplementedSettingsServiceServer
+ command *command.Commands
+ query *query.Queries
+ assetsAPIDomain func(context.Context) string
+}
+
+type Config struct{}
+
+func CreateServer(
+ command *command.Commands,
+ query *query.Queries,
+ externalSecure bool,
+) *Server {
+ return &Server{
+ command: command,
+ query: query,
+ assetsAPIDomain: assets.AssetAPI(externalSecure),
+ }
+}
+
+func (s *Server) RegisterServer(grpcServer *grpc.Server) {
+ settings.RegisterSettingsServiceServer(grpcServer, s)
+}
+
+func (s *Server) AppName() string {
+ return settings.SettingsService_ServiceDesc.ServiceName
+}
+
+func (s *Server) MethodPrefix() string {
+ return settings.SettingsService_ServiceDesc.ServiceName
+}
+
+func (s *Server) AuthMethods() authz.MethodMapping {
+ return settings.SettingsService_AuthMethods
+}
+
+func (s *Server) RegisterGateway() server.RegisterGatewayFunc {
+ return settings.RegisterSettingsServiceHandler
+}
diff --git a/internal/api/grpc/settings/v2beta/server_integration_test.go b/internal/api/grpc/settings/v2beta/server_integration_test.go
new file mode 100644
index 0000000000..34afe5733d
--- /dev/null
+++ b/internal/api/grpc/settings/v2beta/server_integration_test.go
@@ -0,0 +1,34 @@
+//go:build integration
+
+package settings_test
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/zitadel/zitadel/internal/integration"
+ settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta"
+)
+
+var (
+ CTX, AdminCTX context.Context
+ Tester *integration.Tester
+ Client settings.SettingsServiceClient
+)
+
+func TestMain(m *testing.M) {
+ os.Exit(func() int {
+ ctx, _, cancel := integration.Contexts(3 * time.Minute)
+ defer cancel()
+
+ Tester = integration.NewTester(ctx)
+ defer Tester.Done()
+
+ CTX = ctx
+ AdminCTX = Tester.WithAuthorization(ctx, integration.IAMOwner)
+ Client = Tester.Client.SettingsV2beta
+ return m.Run()
+ }())
+}
diff --git a/internal/api/grpc/settings/v2beta/settings.go b/internal/api/grpc/settings/v2beta/settings.go
new file mode 100644
index 0000000000..677d8f1c15
--- /dev/null
+++ b/internal/api/grpc/settings/v2beta/settings.go
@@ -0,0 +1,161 @@
+package settings
+
+import (
+ "context"
+
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
+ object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/i18n"
+ "github.com/zitadel/zitadel/internal/query"
+ object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta"
+)
+
+func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) {
+ current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
+ if err != nil {
+ return nil, err
+ }
+ return &settings.GetLoginSettingsResponse{
+ Settings: loginSettingsToPb(current),
+ Details: &object_pb.Details{
+ Sequence: current.Sequence,
+ ChangeDate: timestamppb.New(current.ChangeDate),
+ ResourceOwner: current.OrgID,
+ },
+ }, nil
+}
+
+func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *settings.GetPasswordComplexitySettingsRequest) (*settings.GetPasswordComplexitySettingsResponse, error) {
+ current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
+ if err != nil {
+ return nil, err
+ }
+ return &settings.GetPasswordComplexitySettingsResponse{
+ Settings: passwordComplexitySettingsToPb(current),
+ Details: &object_pb.Details{
+ Sequence: current.Sequence,
+ ChangeDate: timestamppb.New(current.ChangeDate),
+ ResourceOwner: current.ResourceOwner,
+ },
+ }, nil
+}
+
+func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.GetPasswordExpirySettingsRequest) (*settings.GetPasswordExpirySettingsResponse, error) {
+ current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
+ if err != nil {
+ return nil, err
+ }
+ return &settings.GetPasswordExpirySettingsResponse{
+ Settings: passwordExpirySettingsToPb(current),
+ Details: &object_pb.Details{
+ Sequence: current.Sequence,
+ ChangeDate: timestamppb.New(current.ChangeDate),
+ ResourceOwner: current.ResourceOwner,
+ },
+ }, nil
+}
+
+func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrandingSettingsRequest) (*settings.GetBrandingSettingsResponse, error) {
+ current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
+ if err != nil {
+ return nil, err
+ }
+ return &settings.GetBrandingSettingsResponse{
+ Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)),
+ Details: &object_pb.Details{
+ Sequence: current.Sequence,
+ ChangeDate: timestamppb.New(current.ChangeDate),
+ ResourceOwner: current.ResourceOwner,
+ },
+ }, nil
+}
+
+func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainSettingsRequest) (*settings.GetDomainSettingsResponse, error) {
+ current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
+ if err != nil {
+ return nil, err
+ }
+ return &settings.GetDomainSettingsResponse{
+ Settings: domainSettingsToPb(current),
+ Details: &object_pb.Details{
+ Sequence: current.Sequence,
+ ChangeDate: timestamppb.New(current.ChangeDate),
+ ResourceOwner: current.ResourceOwner,
+ },
+ }, nil
+}
+
+func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.GetLegalAndSupportSettingsRequest) (*settings.GetLegalAndSupportSettingsResponse, error) {
+ current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
+ if err != nil {
+ return nil, err
+ }
+ return &settings.GetLegalAndSupportSettingsResponse{
+ Settings: legalAndSupportSettingsToPb(current),
+ Details: &object_pb.Details{
+ Sequence: current.Sequence,
+ ChangeDate: timestamppb.New(current.ChangeDate),
+ ResourceOwner: current.ResourceOwner,
+ },
+ }, nil
+}
+
+func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockoutSettingsRequest) (*settings.GetLockoutSettingsResponse, error) {
+ current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()))
+ if err != nil {
+ return nil, err
+ }
+ return &settings.GetLockoutSettingsResponse{
+ Settings: lockoutSettingsToPb(current),
+ Details: &object_pb.Details{
+ Sequence: current.Sequence,
+ ChangeDate: timestamppb.New(current.ChangeDate),
+ ResourceOwner: current.ResourceOwner,
+ },
+ }, nil
+}
+
+func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) {
+ links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{}, false)
+ if err != nil {
+ return nil, err
+ }
+
+ return &settings.GetActiveIdentityProvidersResponse{
+ Details: object.ToListDetails(links.SearchResponse),
+ IdentityProviders: identityProvidersToPb(links.Links),
+ }, nil
+}
+
+func (s *Server) GetGeneralSettings(ctx context.Context, _ *settings.GetGeneralSettingsRequest) (*settings.GetGeneralSettingsResponse, error) {
+ instance := authz.GetInstance(ctx)
+ return &settings.GetGeneralSettingsResponse{
+ SupportedLanguages: domain.LanguagesToStrings(i18n.SupportedLanguages()),
+ DefaultOrgId: instance.DefaultOrganisationID(),
+ DefaultLanguage: instance.DefaultLanguage().String(),
+ }, nil
+}
+
+func (s *Server) GetSecuritySettings(ctx context.Context, req *settings.GetSecuritySettingsRequest) (*settings.GetSecuritySettingsResponse, error) {
+ policy, err := s.query.SecurityPolicy(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return &settings.GetSecuritySettingsResponse{
+ Settings: securityPolicyToSettingsPb(policy),
+ }, nil
+}
+
+func (s *Server) SetSecuritySettings(ctx context.Context, req *settings.SetSecuritySettingsRequest) (*settings.SetSecuritySettingsResponse, error) {
+ details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req))
+ if err != nil {
+ return nil, err
+ }
+ return &settings.SetSecuritySettingsResponse{
+ Details: object.DomainToDetailsPb(details),
+ }, nil
+}
diff --git a/internal/api/grpc/settings/v2beta/settings_converter.go b/internal/api/grpc/settings/v2beta/settings_converter.go
new file mode 100644
index 0000000000..2b20e738e1
--- /dev/null
+++ b/internal/api/grpc/settings/v2beta/settings_converter.go
@@ -0,0 +1,245 @@
+package settings
+
+import (
+ "time"
+
+ "google.golang.org/protobuf/types/known/durationpb"
+
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/query"
+ settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta"
+)
+
+func loginSettingsToPb(current *query.LoginPolicy) *settings.LoginSettings {
+ multi := make([]settings.MultiFactorType, len(current.MultiFactors))
+ for i, typ := range current.MultiFactors {
+ multi[i] = multiFactorTypeToPb(typ)
+ }
+ second := make([]settings.SecondFactorType, len(current.SecondFactors))
+ for i, typ := range current.SecondFactors {
+ second[i] = secondFactorTypeToPb(typ)
+ }
+
+ return &settings.LoginSettings{
+ AllowUsernamePassword: current.AllowUsernamePassword,
+ AllowRegister: current.AllowRegister,
+ AllowExternalIdp: current.AllowExternalIDPs,
+ ForceMfa: current.ForceMFA,
+ ForceMfaLocalOnly: current.ForceMFALocalOnly,
+ PasskeysType: passkeysTypeToPb(current.PasswordlessType),
+ HidePasswordReset: current.HidePasswordReset,
+ IgnoreUnknownUsernames: current.IgnoreUnknownUsernames,
+ AllowDomainDiscovery: current.AllowDomainDiscovery,
+ DisableLoginWithEmail: current.DisableLoginWithEmail,
+ DisableLoginWithPhone: current.DisableLoginWithPhone,
+ DefaultRedirectUri: current.DefaultRedirectURI,
+ PasswordCheckLifetime: durationpb.New(time.Duration(current.PasswordCheckLifetime)),
+ ExternalLoginCheckLifetime: durationpb.New(time.Duration(current.ExternalLoginCheckLifetime)),
+ MfaInitSkipLifetime: durationpb.New(time.Duration(current.MFAInitSkipLifetime)),
+ SecondFactorCheckLifetime: durationpb.New(time.Duration(current.SecondFactorCheckLifetime)),
+ MultiFactorCheckLifetime: durationpb.New(time.Duration(current.MultiFactorCheckLifetime)),
+ SecondFactors: second,
+ MultiFactors: multi,
+ ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
+ }
+}
+
+func isDefaultToResourceOwnerTypePb(isDefault bool) settings.ResourceOwnerType {
+ if isDefault {
+ return settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE
+ }
+ return settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_ORG
+}
+
+func passkeysTypeToPb(passwordlessType domain.PasswordlessType) settings.PasskeysType {
+ switch passwordlessType {
+ case domain.PasswordlessTypeAllowed:
+ return settings.PasskeysType_PASSKEYS_TYPE_ALLOWED
+ case domain.PasswordlessTypeNotAllowed:
+ return settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED
+ default:
+ return settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED
+ }
+}
+
+func secondFactorTypeToPb(secondFactorType domain.SecondFactorType) settings.SecondFactorType {
+ switch secondFactorType {
+ case domain.SecondFactorTypeTOTP:
+ return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP
+ case domain.SecondFactorTypeU2F:
+ return settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F
+ case domain.SecondFactorTypeOTPEmail:
+ return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL
+ case domain.SecondFactorTypeOTPSMS:
+ return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS
+ case domain.SecondFactorTypeUnspecified:
+ return settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED
+ default:
+ return settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED
+ }
+}
+
+func multiFactorTypeToPb(typ domain.MultiFactorType) settings.MultiFactorType {
+ switch typ {
+ case domain.MultiFactorTypeU2FWithPIN:
+ return settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION
+ case domain.MultiFactorTypeUnspecified:
+ return settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED
+ default:
+ return settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED
+ }
+}
+
+func passwordComplexitySettingsToPb(current *query.PasswordComplexityPolicy) *settings.PasswordComplexitySettings {
+ return &settings.PasswordComplexitySettings{
+ MinLength: current.MinLength,
+ RequiresUppercase: current.HasUppercase,
+ RequiresLowercase: current.HasLowercase,
+ RequiresNumber: current.HasNumber,
+ RequiresSymbol: current.HasSymbol,
+ ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
+ }
+}
+
+func passwordExpirySettingsToPb(current *query.PasswordAgePolicy) *settings.PasswordExpirySettings {
+ return &settings.PasswordExpirySettings{
+ MaxAgeDays: current.MaxAgeDays,
+ ExpireWarnDays: current.ExpireWarnDays,
+ ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
+ }
+}
+
+func brandingSettingsToPb(current *query.LabelPolicy, assetPrefix string) *settings.BrandingSettings {
+ return &settings.BrandingSettings{
+ LightTheme: themeToPb(current.Light, assetPrefix, current.ResourceOwner),
+ DarkTheme: themeToPb(current.Dark, assetPrefix, current.ResourceOwner),
+ FontUrl: domain.AssetURL(assetPrefix, current.ResourceOwner, current.FontURL),
+ DisableWatermark: current.WatermarkDisabled,
+ HideLoginNameSuffix: current.HideLoginNameSuffix,
+ ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
+ ThemeMode: themeModeToPb(current.ThemeMode),
+ }
+}
+
+func themeModeToPb(themeMode domain.LabelPolicyThemeMode) settings.ThemeMode {
+ switch themeMode {
+ case domain.LabelPolicyThemeAuto:
+ return settings.ThemeMode_THEME_MODE_AUTO
+ case domain.LabelPolicyThemeLight:
+ return settings.ThemeMode_THEME_MODE_LIGHT
+ case domain.LabelPolicyThemeDark:
+ return settings.ThemeMode_THEME_MODE_DARK
+ default:
+ return settings.ThemeMode_THEME_MODE_AUTO
+ }
+}
+
+func themeToPb(theme query.Theme, assetPrefix, resourceOwner string) *settings.Theme {
+ return &settings.Theme{
+ PrimaryColor: theme.PrimaryColor,
+ BackgroundColor: theme.BackgroundColor,
+ FontColor: theme.FontColor,
+ WarnColor: theme.WarnColor,
+ LogoUrl: domain.AssetURL(assetPrefix, resourceOwner, theme.LogoURL),
+ IconUrl: domain.AssetURL(assetPrefix, resourceOwner, theme.IconURL),
+ }
+}
+
+func domainSettingsToPb(current *query.DomainPolicy) *settings.DomainSettings {
+ return &settings.DomainSettings{
+ LoginNameIncludesDomain: current.UserLoginMustBeDomain,
+ RequireOrgDomainVerification: current.ValidateOrgDomains,
+ SmtpSenderAddressMatchesInstanceDomain: current.SMTPSenderAddressMatchesInstanceDomain,
+ ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
+ }
+}
+
+func legalAndSupportSettingsToPb(current *query.PrivacyPolicy) *settings.LegalAndSupportSettings {
+ return &settings.LegalAndSupportSettings{
+ TosLink: current.TOSLink,
+ PrivacyPolicyLink: current.PrivacyLink,
+ HelpLink: current.HelpLink,
+ SupportEmail: string(current.SupportEmail),
+ ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
+ DocsLink: current.DocsLink,
+ CustomLink: current.CustomLink,
+ CustomLinkText: current.CustomLinkText,
+ }
+}
+
+func lockoutSettingsToPb(current *query.LockoutPolicy) *settings.LockoutSettings {
+ return &settings.LockoutSettings{
+ MaxPasswordAttempts: current.MaxPasswordAttempts,
+ MaxOtpAttempts: current.MaxOTPAttempts,
+ ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
+ }
+}
+
+func identityProvidersToPb(idps []*query.IDPLoginPolicyLink) []*settings.IdentityProvider {
+ providers := make([]*settings.IdentityProvider, len(idps))
+ for i, idp := range idps {
+ providers[i] = identityProviderToPb(idp)
+ }
+ return providers
+}
+
+func identityProviderToPb(idp *query.IDPLoginPolicyLink) *settings.IdentityProvider {
+ return &settings.IdentityProvider{
+ Id: idp.IDPID,
+ Name: domain.IDPName(idp.IDPName, idp.IDPType),
+ Type: idpTypeToPb(idp.IDPType),
+ }
+}
+
+func idpTypeToPb(idpType domain.IDPType) settings.IdentityProviderType {
+ switch idpType {
+ case domain.IDPTypeUnspecified:
+ return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED
+ case domain.IDPTypeOIDC:
+ return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OIDC
+ case domain.IDPTypeJWT:
+ return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_JWT
+ case domain.IDPTypeOAuth:
+ return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OAUTH
+ case domain.IDPTypeLDAP:
+ return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_LDAP
+ case domain.IDPTypeAzureAD:
+ return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_AZURE_AD
+ case domain.IDPTypeGitHub:
+ return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB
+ case domain.IDPTypeGitHubEnterprise:
+ return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB_ES
+ case domain.IDPTypeGitLab:
+ return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB
+ case domain.IDPTypeGitLabSelfHosted:
+ return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB_SELF_HOSTED
+ case domain.IDPTypeGoogle:
+ return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GOOGLE
+ case domain.IDPTypeSAML:
+ return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_SAML
+ case domain.IDPTypeApple:
+ // Handle all remaining cases so the linter succeeds
+ return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED
+ default:
+ return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED
+ }
+}
+
+func securityPolicyToSettingsPb(policy *query.SecurityPolicy) *settings.SecuritySettings {
+ return &settings.SecuritySettings{
+ EmbeddedIframe: &settings.EmbeddedIframeSettings{
+ Enabled: policy.EnableIframeEmbedding,
+ AllowedOrigins: policy.AllowedOrigins,
+ },
+ EnableImpersonation: policy.EnableImpersonation,
+ }
+}
+
+func securitySettingsToCommand(req *settings.SetSecuritySettingsRequest) *command.SecurityPolicy {
+ return &command.SecurityPolicy{
+ EnableIframeEmbedding: req.GetEmbeddedIframe().GetEnabled(),
+ AllowedOrigins: req.GetEmbeddedIframe().GetAllowedOrigins(),
+ EnableImpersonation: req.GetEnableImpersonation(),
+ }
+}
diff --git a/internal/api/grpc/settings/v2beta/settings_converter_test.go b/internal/api/grpc/settings/v2beta/settings_converter_test.go
new file mode 100644
index 0000000000..99c60f2628
--- /dev/null
+++ b/internal/api/grpc/settings/v2beta/settings_converter_test.go
@@ -0,0 +1,517 @@
+package settings
+
+import (
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/reflect/protoreflect"
+ "google.golang.org/protobuf/types/known/durationpb"
+
+ "github.com/zitadel/zitadel/internal/api/grpc"
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/database"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/query"
+ settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta"
+)
+
+var ignoreTypes = []protoreflect.FullName{"google.protobuf.Duration"}
+
+func Test_loginSettingsToPb(t *testing.T) {
+ arg := &query.LoginPolicy{
+ AllowUsernamePassword: true,
+ AllowRegister: true,
+ AllowExternalIDPs: true,
+ ForceMFA: true,
+ ForceMFALocalOnly: true,
+ PasswordlessType: domain.PasswordlessTypeAllowed,
+ HidePasswordReset: true,
+ IgnoreUnknownUsernames: true,
+ AllowDomainDiscovery: true,
+ DisableLoginWithEmail: true,
+ DisableLoginWithPhone: true,
+ DefaultRedirectURI: "example.com",
+ PasswordCheckLifetime: database.Duration(time.Hour),
+ ExternalLoginCheckLifetime: database.Duration(time.Minute),
+ MFAInitSkipLifetime: database.Duration(time.Millisecond),
+ SecondFactorCheckLifetime: database.Duration(time.Microsecond),
+ MultiFactorCheckLifetime: database.Duration(time.Nanosecond),
+ SecondFactors: []domain.SecondFactorType{
+ domain.SecondFactorTypeTOTP,
+ domain.SecondFactorTypeU2F,
+ domain.SecondFactorTypeOTPEmail,
+ domain.SecondFactorTypeOTPSMS,
+ },
+ MultiFactors: []domain.MultiFactorType{
+ domain.MultiFactorTypeU2FWithPIN,
+ },
+ IsDefault: true,
+ }
+
+ want := &settings.LoginSettings{
+ AllowUsernamePassword: true,
+ AllowRegister: true,
+ AllowExternalIdp: true,
+ ForceMfa: true,
+ ForceMfaLocalOnly: true,
+ PasskeysType: settings.PasskeysType_PASSKEYS_TYPE_ALLOWED,
+ HidePasswordReset: true,
+ IgnoreUnknownUsernames: true,
+ AllowDomainDiscovery: true,
+ DisableLoginWithEmail: true,
+ DisableLoginWithPhone: true,
+ DefaultRedirectUri: "example.com",
+ PasswordCheckLifetime: durationpb.New(time.Hour),
+ ExternalLoginCheckLifetime: durationpb.New(time.Minute),
+ MfaInitSkipLifetime: durationpb.New(time.Millisecond),
+ SecondFactorCheckLifetime: durationpb.New(time.Microsecond),
+ MultiFactorCheckLifetime: durationpb.New(time.Nanosecond),
+ SecondFactors: []settings.SecondFactorType{
+ settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP,
+ settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F,
+ settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL,
+ settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS,
+ },
+ MultiFactors: []settings.MultiFactorType{
+ settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION,
+ },
+ ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
+ }
+
+ got := loginSettingsToPb(arg)
+ grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
+ if !proto.Equal(got, want) {
+ t.Errorf("loginSettingsToPb() =\n%v\nwant\n%v", got, want)
+ }
+}
+
+func Test_isDefaultToResourceOwnerTypePb(t *testing.T) {
+ type args struct {
+ isDefault bool
+ }
+ tests := []struct {
+ args args
+ want settings.ResourceOwnerType
+ }{
+ {
+ args: args{false},
+ want: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_ORG,
+ },
+ {
+ args: args{true},
+ want: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.want.String(), func(t *testing.T) {
+ got := isDefaultToResourceOwnerTypePb(tt.args.isDefault)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func Test_passkeysTypeToPb(t *testing.T) {
+ type args struct {
+ passwordlessType domain.PasswordlessType
+ }
+ tests := []struct {
+ args args
+ want settings.PasskeysType
+ }{
+ {
+ args: args{domain.PasswordlessTypeNotAllowed},
+ want: settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED,
+ },
+ {
+ args: args{domain.PasswordlessTypeAllowed},
+ want: settings.PasskeysType_PASSKEYS_TYPE_ALLOWED,
+ },
+ {
+ args: args{99},
+ want: settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.want.String(), func(t *testing.T) {
+ got := passkeysTypeToPb(tt.args.passwordlessType)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func Test_secondFactorTypeToPb(t *testing.T) {
+ type args struct {
+ secondFactorType domain.SecondFactorType
+ }
+ tests := []struct {
+ args args
+ want settings.SecondFactorType
+ }{
+ {
+ args: args{domain.SecondFactorTypeTOTP},
+ want: settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP,
+ },
+ {
+ args: args{domain.SecondFactorTypeU2F},
+ want: settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F,
+ },
+ {
+ args: args{domain.SecondFactorTypeOTPSMS},
+ want: settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS,
+ },
+ {
+ args: args{domain.SecondFactorTypeOTPEmail},
+ want: settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL,
+ },
+ {
+ args: args{domain.SecondFactorTypeUnspecified},
+ want: settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED,
+ },
+ {
+ args: args{99},
+ want: settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.want.String(), func(t *testing.T) {
+ got := secondFactorTypeToPb(tt.args.secondFactorType)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func Test_multiFactorTypeToPb(t *testing.T) {
+ type args struct {
+ typ domain.MultiFactorType
+ }
+ tests := []struct {
+ args args
+ want settings.MultiFactorType
+ }{
+ {
+ args: args{domain.MultiFactorTypeU2FWithPIN},
+ want: settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION,
+ },
+ {
+ args: args{domain.MultiFactorTypeUnspecified},
+ want: settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED,
+ },
+ {
+ args: args{99},
+ want: settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.want.String(), func(t *testing.T) {
+ got := multiFactorTypeToPb(tt.args.typ)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func Test_passwordComplexitySettingsToPb(t *testing.T) {
+ arg := &query.PasswordComplexityPolicy{
+ MinLength: 12,
+ HasUppercase: true,
+ HasLowercase: true,
+ HasNumber: true,
+ HasSymbol: true,
+ IsDefault: true,
+ }
+ want := &settings.PasswordComplexitySettings{
+ MinLength: 12,
+ RequiresUppercase: true,
+ RequiresLowercase: true,
+ RequiresNumber: true,
+ RequiresSymbol: true,
+ ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
+ }
+
+ got := passwordComplexitySettingsToPb(arg)
+ grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
+ if !proto.Equal(got, want) {
+ t.Errorf("passwordComplexitySettingsToPb() =\n%v\nwant\n%v", got, want)
+ }
+}
+
+func Test_passwordExpirySettingsToPb(t *testing.T) {
+ arg := &query.PasswordAgePolicy{
+ ExpireWarnDays: 80,
+ MaxAgeDays: 90,
+ IsDefault: true,
+ }
+ want := &settings.PasswordExpirySettings{
+ ExpireWarnDays: 80,
+ MaxAgeDays: 90,
+ ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
+ }
+
+ got := passwordExpirySettingsToPb(arg)
+ grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
+ if !proto.Equal(got, want) {
+ t.Errorf("passwordExpirySettingsToPb() =\n%v\nwant\n%v", got, want)
+ }
+}
+
+func Test_brandingSettingsToPb(t *testing.T) {
+ arg := &query.LabelPolicy{
+ Light: query.Theme{
+ PrimaryColor: "red",
+ WarnColor: "white",
+ BackgroundColor: "blue",
+ FontColor: "orange",
+ LogoURL: "light-logo",
+ IconURL: "light-icon",
+ },
+ Dark: query.Theme{
+ PrimaryColor: "magenta",
+ WarnColor: "pink",
+ BackgroundColor: "black",
+ FontColor: "white",
+ LogoURL: "dark-logo",
+ IconURL: "dark-icon",
+ },
+ ResourceOwner: "me",
+ FontURL: "fonts",
+ WatermarkDisabled: true,
+ HideLoginNameSuffix: true,
+ ThemeMode: domain.LabelPolicyThemeDark,
+ IsDefault: true,
+ }
+ want := &settings.BrandingSettings{
+ LightTheme: &settings.Theme{
+ PrimaryColor: "red",
+ WarnColor: "white",
+ BackgroundColor: "blue",
+ FontColor: "orange",
+ LogoUrl: "http://example.com/me/light-logo",
+ IconUrl: "http://example.com/me/light-icon",
+ },
+ DarkTheme: &settings.Theme{
+ PrimaryColor: "magenta",
+ WarnColor: "pink",
+ BackgroundColor: "black",
+ FontColor: "white",
+ LogoUrl: "http://example.com/me/dark-logo",
+ IconUrl: "http://example.com/me/dark-icon",
+ },
+ FontUrl: "http://example.com/me/fonts",
+ DisableWatermark: true,
+ HideLoginNameSuffix: true,
+ ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
+ ThemeMode: settings.ThemeMode_THEME_MODE_DARK,
+ }
+
+ got := brandingSettingsToPb(arg, "http://example.com")
+ grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
+ if !proto.Equal(got, want) {
+ t.Errorf("brandingSettingsToPb() =\n%v\nwant\n%v", got, want)
+ }
+}
+
+func Test_domainSettingsToPb(t *testing.T) {
+ arg := &query.DomainPolicy{
+ UserLoginMustBeDomain: true,
+ ValidateOrgDomains: true,
+ SMTPSenderAddressMatchesInstanceDomain: true,
+ IsDefault: true,
+ }
+ want := &settings.DomainSettings{
+ LoginNameIncludesDomain: true,
+ RequireOrgDomainVerification: true,
+ SmtpSenderAddressMatchesInstanceDomain: true,
+ ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
+ }
+ got := domainSettingsToPb(arg)
+ grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
+ if !proto.Equal(got, want) {
+ t.Errorf("domainSettingsToPb() =\n%v\nwant\n%v", got, want)
+ }
+}
+
+func Test_legalSettingsToPb(t *testing.T) {
+ arg := &query.PrivacyPolicy{
+ TOSLink: "http://example.com/tos",
+ PrivacyLink: "http://example.com/pricacy",
+ HelpLink: "http://example.com/help",
+ SupportEmail: "support@zitadel.com",
+ IsDefault: true,
+ DocsLink: "http://example.com/docs",
+ CustomLink: "http://example.com/custom",
+ CustomLinkText: "Custom",
+ }
+ want := &settings.LegalAndSupportSettings{
+ TosLink: "http://example.com/tos",
+ PrivacyPolicyLink: "http://example.com/pricacy",
+ HelpLink: "http://example.com/help",
+ SupportEmail: "support@zitadel.com",
+ DocsLink: "http://example.com/docs",
+ CustomLink: "http://example.com/custom",
+ CustomLinkText: "Custom",
+ ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
+ }
+ got := legalAndSupportSettingsToPb(arg)
+ grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
+ if !proto.Equal(got, want) {
+ t.Errorf("legalSettingsToPb() =\n%v\nwant\n%v", got, want)
+ }
+}
+
+func Test_lockoutSettingsToPb(t *testing.T) {
+ arg := &query.LockoutPolicy{
+ MaxPasswordAttempts: 22,
+ MaxOTPAttempts: 22,
+ IsDefault: true,
+ }
+ want := &settings.LockoutSettings{
+ MaxPasswordAttempts: 22,
+ MaxOtpAttempts: 22,
+ ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
+ }
+ got := lockoutSettingsToPb(arg)
+ grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
+ if !proto.Equal(got, want) {
+ t.Errorf("lockoutSettingsToPb() =\n%v\nwant\n%v", got, want)
+ }
+}
+
+func Test_identityProvidersToPb(t *testing.T) {
+ arg := []*query.IDPLoginPolicyLink{
+ {
+ IDPID: "1",
+ IDPName: "foo",
+ IDPType: domain.IDPTypeOIDC,
+ },
+ {
+ IDPID: "2",
+ IDPName: "bar",
+ IDPType: domain.IDPTypeGitHub,
+ },
+ }
+ want := []*settings.IdentityProvider{
+ {
+ Id: "1",
+ Name: "foo",
+ Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OIDC,
+ },
+ {
+ Id: "2",
+ Name: "bar",
+ Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB,
+ },
+ }
+ got := identityProvidersToPb(arg)
+ require.Len(t, got, len(got))
+ for i, v := range got {
+ grpc.AllFieldsSet(t, v.ProtoReflect(), ignoreTypes...)
+ if !proto.Equal(v, want[i]) {
+ t.Errorf("identityProvidersToPb() =\n%v\nwant\n%v", got, want)
+ }
+ }
+}
+
+func Test_idpTypeToPb(t *testing.T) {
+ type args struct {
+ idpType domain.IDPType
+ }
+ tests := []struct {
+ args args
+ want settings.IdentityProviderType
+ }{
+ {
+ args: args{domain.IDPTypeUnspecified},
+ want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED,
+ },
+ {
+ args: args{domain.IDPTypeOIDC},
+ want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OIDC,
+ },
+ {
+ args: args{domain.IDPTypeJWT},
+ want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_JWT,
+ },
+ {
+ args: args{domain.IDPTypeOAuth},
+ want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OAUTH,
+ },
+ {
+ args: args{domain.IDPTypeLDAP},
+ want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_LDAP,
+ },
+ {
+ args: args{domain.IDPTypeAzureAD},
+ want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_AZURE_AD,
+ },
+ {
+ args: args{domain.IDPTypeGitHub},
+ want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB,
+ },
+ {
+ args: args{domain.IDPTypeGitHubEnterprise},
+ want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB_ES,
+ },
+ {
+ args: args{domain.IDPTypeGitLab},
+ want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB,
+ },
+ {
+ args: args{domain.IDPTypeGitLabSelfHosted},
+ want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB_SELF_HOSTED,
+ },
+ {
+ args: args{domain.IDPTypeGoogle},
+ want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GOOGLE,
+ },
+ {
+ args: args{domain.IDPTypeSAML},
+ want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_SAML,
+ },
+ {
+ args: args{99},
+ want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.want.String(), func(t *testing.T) {
+ if got := idpTypeToPb(tt.args.idpType); !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("idpTypeToPb() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_securityPolicyToSettingsPb(t *testing.T) {
+ want := &settings.SecuritySettings{
+ EmbeddedIframe: &settings.EmbeddedIframeSettings{
+ Enabled: true,
+ AllowedOrigins: []string{"foo", "bar"},
+ },
+ EnableImpersonation: true,
+ }
+ got := securityPolicyToSettingsPb(&query.SecurityPolicy{
+ EnableIframeEmbedding: true,
+ AllowedOrigins: []string{"foo", "bar"},
+ EnableImpersonation: true,
+ })
+ assert.Equal(t, want, got)
+}
+
+func Test_securitySettingsToCommand(t *testing.T) {
+ want := &command.SecurityPolicy{
+ EnableIframeEmbedding: true,
+ AllowedOrigins: []string{"foo", "bar"},
+ EnableImpersonation: true,
+ }
+ got := securitySettingsToCommand(&settings.SetSecuritySettingsRequest{
+ EmbeddedIframe: &settings.EmbeddedIframeSettings{
+ Enabled: true,
+ AllowedOrigins: []string{"foo", "bar"},
+ },
+ EnableImpersonation: true,
+ })
+ assert.Equal(t, want, got)
+}
diff --git a/internal/api/grpc/settings/v2beta/settings_integration_test.go b/internal/api/grpc/settings/v2beta/settings_integration_test.go
new file mode 100644
index 0000000000..3accc0d63f
--- /dev/null
+++ b/internal/api/grpc/settings/v2beta/settings_integration_test.go
@@ -0,0 +1,174 @@
+//go:build integration
+
+package settings_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/integration"
+ object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta"
+)
+
+func TestServer_GetSecuritySettings(t *testing.T) {
+ _, err := Client.SetSecuritySettings(AdminCTX, &settings.SetSecuritySettingsRequest{
+ EmbeddedIframe: &settings.EmbeddedIframeSettings{
+ Enabled: true,
+ AllowedOrigins: []string{"foo", "bar"},
+ },
+ EnableImpersonation: true,
+ })
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ ctx context.Context
+ want *settings.GetSecuritySettingsResponse
+ wantErr bool
+ }{
+ {
+ name: "permission error",
+ ctx: Tester.WithAuthorization(CTX, integration.OrgOwner),
+ wantErr: true,
+ },
+ {
+ name: "success",
+ ctx: AdminCTX,
+ want: &settings.GetSecuritySettingsResponse{
+ Settings: &settings.SecuritySettings{
+ EmbeddedIframe: &settings.EmbeddedIframeSettings{
+ Enabled: true,
+ AllowedOrigins: []string{"foo", "bar"},
+ },
+ EnableImpersonation: true,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ resp, err := Client.GetSecuritySettings(tt.ctx, &settings.GetSecuritySettingsRequest{})
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ got, want := resp.GetSettings(), tt.want.GetSettings()
+ assert.Equal(t, want.GetEmbeddedIframe().GetEnabled(), got.GetEmbeddedIframe().GetEnabled(), "enable iframe embedding")
+ assert.Equal(t, want.GetEmbeddedIframe().GetAllowedOrigins(), got.GetEmbeddedIframe().GetAllowedOrigins(), "allowed origins")
+ assert.Equal(t, want.GetEnableImpersonation(), got.GetEnableImpersonation(), "enable impersonation")
+ })
+ }
+}
+
+func TestServer_SetSecuritySettings(t *testing.T) {
+ type args struct {
+ ctx context.Context
+ req *settings.SetSecuritySettingsRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *settings.SetSecuritySettingsResponse
+ wantErr bool
+ }{
+ {
+ name: "permission error",
+ args: args{
+ ctx: Tester.WithAuthorization(CTX, integration.OrgOwner),
+ req: &settings.SetSecuritySettingsRequest{
+ EmbeddedIframe: &settings.EmbeddedIframeSettings{
+ Enabled: true,
+ AllowedOrigins: []string{"foo.com", "bar.com"},
+ },
+ EnableImpersonation: true,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "success allowed origins",
+ args: args{
+ ctx: AdminCTX,
+ req: &settings.SetSecuritySettingsRequest{
+ EmbeddedIframe: &settings.EmbeddedIframeSettings{
+ AllowedOrigins: []string{"foo.com", "bar.com"},
+ },
+ },
+ },
+ want: &settings.SetSecuritySettingsResponse{
+ Details: &object_pb.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ },
+ },
+ {
+ name: "success enable iframe embedding",
+ args: args{
+ ctx: AdminCTX,
+ req: &settings.SetSecuritySettingsRequest{
+ EmbeddedIframe: &settings.EmbeddedIframeSettings{
+ Enabled: true,
+ },
+ },
+ },
+ want: &settings.SetSecuritySettingsResponse{
+ Details: &object_pb.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ },
+ },
+ {
+ name: "success impersonation",
+ args: args{
+ ctx: AdminCTX,
+ req: &settings.SetSecuritySettingsRequest{
+ EnableImpersonation: true,
+ },
+ },
+ want: &settings.SetSecuritySettingsResponse{
+ Details: &object_pb.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ },
+ },
+ {
+ name: "success all",
+ args: args{
+ ctx: AdminCTX,
+ req: &settings.SetSecuritySettingsRequest{
+ EmbeddedIframe: &settings.EmbeddedIframeSettings{
+ Enabled: true,
+ AllowedOrigins: []string{"foo.com", "bar.com"},
+ },
+ EnableImpersonation: true,
+ },
+ },
+ want: &settings.SetSecuritySettingsResponse{
+ Details: &object_pb.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.SetSecuritySettings(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
diff --git a/internal/api/grpc/user/converter.go b/internal/api/grpc/user/converter.go
index 621d445672..85592b0f12 100644
--- a/internal/api/grpc/user/converter.go
+++ b/internal/api/grpc/user/converter.go
@@ -5,7 +5,6 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc/object"
"github.com/zitadel/zitadel/internal/domain"
- "github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/query"
user_pb "github.com/zitadel/zitadel/pkg/grpc/user"
)
@@ -255,22 +254,6 @@ func UserAuthMethodToWebAuthNTokenPb(token *query.AuthMethod) *user_pb.WebAuthNT
}
}
-func ExternalIDPViewsToExternalIDPs(externalIDPs []*query.IDPUserLink) []*domain.UserIDPLink {
- idps := make([]*domain.UserIDPLink, len(externalIDPs))
- for i, idp := range externalIDPs {
- idps[i] = &domain.UserIDPLink{
- ObjectRoot: models.ObjectRoot{
- AggregateID: idp.UserID,
- ResourceOwner: idp.ResourceOwner,
- },
- IDPConfigID: idp.IDPID,
- ExternalUserID: idp.ProvidedUserID,
- DisplayName: idp.ProvidedUsername,
- }
- }
- return idps
-}
-
func TypeToPb(userType domain.UserType) user_pb.Type {
switch userType {
case domain.UserTypeHuman:
diff --git a/internal/api/grpc/user/schema/v3alpha/schema_integration_test.go b/internal/api/grpc/user/schema/v3alpha/schema_integration_test.go
index f84c338ab4..5cf279144d 100644
--- a/internal/api/grpc/user/schema/v3alpha/schema_integration_test.go
+++ b/internal/api/grpc/user/schema/v3alpha/schema_integration_test.go
@@ -17,8 +17,8 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc"
"github.com/zitadel/zitadel/internal/integration"
- feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/feature/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
schema "github.com/zitadel/zitadel/pkg/grpc/user/schema/v3alpha"
)
diff --git a/internal/api/grpc/user/v2/email.go b/internal/api/grpc/user/v2/email.go
index 38cc73c75c..6d0871b26e 100644
--- a/internal/api/grpc/user/v2/email.go
+++ b/internal/api/grpc/user/v2/email.go
@@ -7,8 +7,8 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/zerrors"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp *user.SetEmailResponse, err error) {
diff --git a/internal/api/grpc/user/v2/email_integration_test.go b/internal/api/grpc/user/v2/email_integration_test.go
index 4034a5e7da..2264934f25 100644
--- a/internal/api/grpc/user/v2/email_integration_test.go
+++ b/internal/api/grpc/user/v2/email_integration_test.go
@@ -12,9 +12,10 @@ import (
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+
"github.com/zitadel/zitadel/internal/integration"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
)
func TestServer_SetEmail(t *testing.T) {
diff --git a/internal/api/grpc/user/v2/idp_link.go b/internal/api/grpc/user/v2/idp_link.go
new file mode 100644
index 0000000000..5567ab24a2
--- /dev/null
+++ b/internal/api/grpc/user/v2/idp_link.go
@@ -0,0 +1,94 @@
+package user
+
+import (
+ "context"
+
+ "github.com/zitadel/zitadel/internal/api/grpc/object/v2"
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/eventstore/v1/models"
+ "github.com/zitadel/zitadel/internal/query"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+)
+
+func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ *user.AddIDPLinkResponse, err error) {
+ details, err := s.command.AddUserIDPLink(ctx, req.UserId, "", &command.AddLink{
+ IDPID: req.GetIdpLink().GetIdpId(),
+ DisplayName: req.GetIdpLink().GetUserName(),
+ IDPExternalID: req.GetIdpLink().GetUserId(),
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &user.AddIDPLinkResponse{
+ Details: object.DomainToDetailsPb(details),
+ }, nil
+}
+
+func (s *Server) ListIDPLinks(ctx context.Context, req *user.ListIDPLinksRequest) (_ *user.ListIDPLinksResponse, err error) {
+ queries, err := ListLinkedIDPsRequestToQuery(req)
+ if err != nil {
+ return nil, err
+ }
+ res, err := s.query.IDPUserLinks(ctx, queries, false)
+ if err != nil {
+ return nil, err
+ }
+ res.RemoveNoPermission(ctx, s.checkPermission)
+ return &user.ListIDPLinksResponse{
+ Result: IDPLinksToPb(res.Links),
+ Details: object.ToListDetails(res.SearchResponse),
+ }, nil
+}
+
+func ListLinkedIDPsRequestToQuery(req *user.ListIDPLinksRequest) (*query.IDPUserLinksSearchQuery, error) {
+ offset, limit, asc := object.ListQueryToQuery(req.Query)
+ userQuery, err := query.NewIDPUserLinksUserIDSearchQuery(req.UserId)
+ if err != nil {
+ return nil, err
+ }
+ return &query.IDPUserLinksSearchQuery{
+ SearchRequest: query.SearchRequest{
+ Offset: offset,
+ Limit: limit,
+ Asc: asc,
+ },
+ Queries: []query.SearchQuery{userQuery},
+ }, nil
+}
+
+func IDPLinksToPb(res []*query.IDPUserLink) []*user.IDPLink {
+ links := make([]*user.IDPLink, len(res))
+ for i, link := range res {
+ links[i] = IDPLinkToPb(link)
+ }
+ return links
+}
+
+func IDPLinkToPb(link *query.IDPUserLink) *user.IDPLink {
+ return &user.IDPLink{
+ IdpId: link.IDPID,
+ UserId: link.ProvidedUserID,
+ UserName: link.ProvidedUsername,
+ }
+}
+
+func (s *Server) RemoveIDPLink(ctx context.Context, req *user.RemoveIDPLinkRequest) (*user.RemoveIDPLinkResponse, error) {
+ objectDetails, err := s.command.RemoveUserIDPLink(ctx, RemoveIDPLinkRequestToDomain(ctx, req))
+ if err != nil {
+ return nil, err
+ }
+ return &user.RemoveIDPLinkResponse{
+ Details: object.DomainToDetailsPb(objectDetails),
+ }, nil
+}
+
+func RemoveIDPLinkRequestToDomain(ctx context.Context, req *user.RemoveIDPLinkRequest) *domain.UserIDPLink {
+ return &domain.UserIDPLink{
+ ObjectRoot: models.ObjectRoot{
+ AggregateID: req.UserId,
+ },
+ IDPConfigID: req.IdpId,
+ ExternalUserID: req.LinkedUserId,
+ }
+}
diff --git a/internal/api/grpc/user/v2/idp_link_integration_test.go b/internal/api/grpc/user/v2/idp_link_integration_test.go
new file mode 100644
index 0000000000..6b85c80f98
--- /dev/null
+++ b/internal/api/grpc/user/v2/idp_link_integration_test.go
@@ -0,0 +1,360 @@
+//go:build integration
+
+package user_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/integration"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+)
+
+func TestServer_AddIDPLink(t *testing.T) {
+ idpID := Tester.AddGenericOAuthProvider(t, CTX)
+ type args struct {
+ ctx context.Context
+ req *user.AddIDPLinkRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.AddIDPLinkResponse
+ wantErr bool
+ }{
+ {
+ name: "user does not exist",
+ args: args{
+ CTX,
+ &user.AddIDPLinkRequest{
+ UserId: "userID",
+ IdpLink: &user.IDPLink{
+ IdpId: idpID,
+ UserId: "userID",
+ UserName: "username",
+ },
+ },
+ },
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "idp does not exist",
+ args: args{
+ CTX,
+ &user.AddIDPLinkRequest{
+ UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID,
+ IdpLink: &user.IDPLink{
+ IdpId: "idpID",
+ UserId: "userID",
+ UserName: "username",
+ },
+ },
+ },
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "add link",
+ args: args{
+ CTX,
+ &user.AddIDPLinkRequest{
+ UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID,
+ IdpLink: &user.IDPLink{
+ IdpId: idpID,
+ UserId: "userID",
+ UserName: "username",
+ },
+ },
+ },
+ want: &user.AddIDPLinkResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.AddIDPLink(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_ListIDPLinks(t *testing.T) {
+ orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("ListIDPLinks%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()))
+
+ instanceIdpID := Tester.AddGenericOAuthProvider(t, IamCTX)
+ userInstanceResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano()))
+ Tester.CreateUserIDPlink(IamCTX, userInstanceResp.GetUserId(), "external_instance", instanceIdpID, "externalUsername_instance")
+
+ orgIdpID := Tester.AddOrgGenericOAuthProvider(t, IamCTX, orgResp.OrganizationId)
+ userOrgResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano()))
+ Tester.CreateUserIDPlink(IamCTX, userOrgResp.GetUserId(), "external_org", orgIdpID, "externalUsername_org")
+
+ userMultipleResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano()))
+ Tester.CreateUserIDPlink(IamCTX, userMultipleResp.GetUserId(), "external_multi", instanceIdpID, "externalUsername_multi")
+ Tester.CreateUserIDPlink(IamCTX, userMultipleResp.GetUserId(), "external_multi", orgIdpID, "externalUsername_multi")
+
+ type args struct {
+ ctx context.Context
+ req *user.ListIDPLinksRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.ListIDPLinksResponse
+ wantErr bool
+ }{
+ {
+ name: "list links, no permission",
+ args: args{
+ UserCTX,
+ &user.ListIDPLinksRequest{
+ UserId: userOrgResp.GetUserId(),
+ },
+ },
+ want: &user.ListIDPLinksResponse{
+ Details: &object.ListDetails{
+ TotalResult: 0,
+ Timestamp: timestamppb.Now(),
+ },
+ Result: []*user.IDPLink{},
+ },
+ },
+ {
+ name: "list links, no permission, org",
+ args: args{
+ CTX,
+ &user.ListIDPLinksRequest{
+ UserId: userOrgResp.GetUserId(),
+ },
+ },
+ want: &user.ListIDPLinksResponse{
+ Details: &object.ListDetails{
+ TotalResult: 0,
+ Timestamp: timestamppb.Now(),
+ },
+ Result: []*user.IDPLink{},
+ },
+ },
+ {
+ name: "list idp links, org, ok",
+ args: args{
+ IamCTX,
+ &user.ListIDPLinksRequest{
+ UserId: userOrgResp.GetUserId(),
+ },
+ },
+ want: &user.ListIDPLinksResponse{
+ Details: &object.ListDetails{
+ TotalResult: 1,
+ Timestamp: timestamppb.Now(),
+ },
+ Result: []*user.IDPLink{
+ {
+ IdpId: orgIdpID,
+ UserId: "external_org",
+ UserName: "externalUsername_org",
+ },
+ },
+ },
+ },
+ {
+ name: "list idp links, instance, ok",
+ args: args{
+ IamCTX,
+ &user.ListIDPLinksRequest{
+ UserId: userInstanceResp.GetUserId(),
+ },
+ },
+ want: &user.ListIDPLinksResponse{
+ Details: &object.ListDetails{
+ TotalResult: 1,
+ Timestamp: timestamppb.Now(),
+ },
+ Result: []*user.IDPLink{
+ {
+ IdpId: instanceIdpID,
+ UserId: "external_instance",
+ UserName: "externalUsername_instance",
+ },
+ },
+ },
+ },
+ {
+ name: "list idp links, multi, ok",
+ args: args{
+ IamCTX,
+ &user.ListIDPLinksRequest{
+ UserId: userMultipleResp.GetUserId(),
+ },
+ },
+ want: &user.ListIDPLinksResponse{
+ Details: &object.ListDetails{
+ TotalResult: 2,
+ Timestamp: timestamppb.Now(),
+ },
+ Result: []*user.IDPLink{
+ {
+ IdpId: instanceIdpID,
+ UserId: "external_multi",
+ UserName: "externalUsername_multi",
+ },
+ {
+ IdpId: orgIdpID,
+ UserId: "external_multi",
+ UserName: "externalUsername_multi",
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ retryDuration := time.Minute
+ if ctxDeadline, ok := CTX.Deadline(); ok {
+ retryDuration = time.Until(ctxDeadline)
+ }
+ require.EventuallyWithT(t, func(ttt *assert.CollectT) {
+ got, listErr := Client.ListIDPLinks(tt.args.ctx, tt.args.req)
+ assertErr := assert.NoError
+ if tt.wantErr {
+ assertErr = assert.Error
+ }
+ assertErr(ttt, listErr)
+ if listErr != nil {
+ return
+ }
+ // always first check length, otherwise its failed anyway
+ assert.Len(ttt, got.Result, len(tt.want.Result))
+ for i := range tt.want.Result {
+ assert.Contains(ttt, got.Result, tt.want.Result[i])
+ }
+ integration.AssertListDetails(t, tt.want, got)
+ }, retryDuration, time.Millisecond*100, "timeout waiting for expected idplinks result")
+ })
+ }
+}
+
+func TestServer_RemoveIDPLink(t *testing.T) {
+ orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("ListIDPLinks%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()))
+
+ instanceIdpID := Tester.AddGenericOAuthProvider(t, IamCTX)
+ userInstanceResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano()))
+ Tester.CreateUserIDPlink(IamCTX, userInstanceResp.GetUserId(), "external_instance", instanceIdpID, "externalUsername_instance")
+
+ orgIdpID := Tester.AddOrgGenericOAuthProvider(t, IamCTX, orgResp.OrganizationId)
+ userOrgResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano()))
+ Tester.CreateUserIDPlink(IamCTX, userOrgResp.GetUserId(), "external_org", orgIdpID, "externalUsername_org")
+
+ userNoLinkResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano()))
+
+ type args struct {
+ ctx context.Context
+ req *user.RemoveIDPLinkRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.RemoveIDPLinkResponse
+ wantErr bool
+ }{
+ {
+ name: "remove link, no permission",
+ args: args{
+ UserCTX,
+ &user.RemoveIDPLinkRequest{
+ UserId: userOrgResp.GetUserId(),
+ IdpId: orgIdpID,
+ LinkedUserId: "external_org",
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "remove link, no permission, org",
+ args: args{
+ CTX,
+ &user.RemoveIDPLinkRequest{
+ UserId: userOrgResp.GetUserId(),
+ IdpId: orgIdpID,
+ LinkedUserId: "external_org",
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "remove link, org, ok",
+ args: args{
+ IamCTX,
+ &user.RemoveIDPLinkRequest{
+ UserId: userOrgResp.GetUserId(),
+ IdpId: orgIdpID,
+ LinkedUserId: "external_org",
+ },
+ },
+ want: &user.RemoveIDPLinkResponse{
+ Details: &object.Details{
+ ResourceOwner: orgResp.GetOrganizationId(),
+ ChangeDate: timestamppb.Now(),
+ },
+ },
+ },
+ {
+ name: "remove link, instance, ok",
+ args: args{
+ IamCTX,
+ &user.RemoveIDPLinkRequest{
+ UserId: userInstanceResp.GetUserId(),
+ IdpId: instanceIdpID,
+ LinkedUserId: "external_instance",
+ },
+ },
+ want: &user.RemoveIDPLinkResponse{
+ Details: &object.Details{
+ ResourceOwner: orgResp.GetOrganizationId(),
+ ChangeDate: timestamppb.Now(),
+ },
+ },
+ },
+ {
+ name: "remove link, no link, error",
+ args: args{
+ IamCTX,
+ &user.RemoveIDPLinkRequest{
+ UserId: userNoLinkResp.GetUserId(),
+ },
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.RemoveIDPLink(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
diff --git a/internal/api/grpc/user/v2/otp.go b/internal/api/grpc/user/v2/otp.go
index 0eae8c6bdd..e2fe6b794d 100644
--- a/internal/api/grpc/user/v2/otp.go
+++ b/internal/api/grpc/user/v2/otp.go
@@ -4,7 +4,7 @@ import (
"context"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func (s *Server) AddOTPSMS(ctx context.Context, req *user.AddOTPSMSRequest) (*user.AddOTPSMSResponse, error) {
diff --git a/internal/api/grpc/user/v2/otp_integration_test.go b/internal/api/grpc/user/v2/otp_integration_test.go
index 7f4c4a0f43..52b30fbd38 100644
--- a/internal/api/grpc/user/v2/otp_integration_test.go
+++ b/internal/api/grpc/user/v2/otp_integration_test.go
@@ -9,9 +9,10 @@ import (
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+
"github.com/zitadel/zitadel/internal/integration"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
)
func TestServer_AddOTPSMS(t *testing.T) {
diff --git a/internal/api/grpc/user/v2/passkey.go b/internal/api/grpc/user/v2/passkey.go
index 58c89ae22e..bf539e252b 100644
--- a/internal/api/grpc/user/v2/passkey.go
+++ b/internal/api/grpc/user/v2/passkey.go
@@ -7,9 +7,10 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
- object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func (s *Server) RegisterPasskey(ctx context.Context, req *user.RegisterPasskeyRequest) (resp *user.RegisterPasskeyResponse, err error) {
@@ -116,3 +117,68 @@ func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*use
},
}, nil
}
+
+func (s *Server) RemovePasskey(ctx context.Context, req *user.RemovePasskeyRequest) (*user.RemovePasskeyResponse, error) {
+ objectDetails, err := s.command.HumanRemovePasswordless(ctx, req.GetUserId(), req.GetPasskeyId(), "")
+ if err != nil {
+ return nil, err
+ }
+ return &user.RemovePasskeyResponse{
+ Details: object.DomainToDetailsPb(objectDetails),
+ }, nil
+}
+
+func (s *Server) ListPasskeys(ctx context.Context, req *user.ListPasskeysRequest) (*user.ListPasskeysResponse, error) {
+ query := new(query.UserAuthMethodSearchQueries)
+ err := query.AppendUserIDQuery(req.UserId)
+ if err != nil {
+ return nil, err
+ }
+ err = query.AppendAuthMethodQuery(domain.UserAuthMethodTypePasswordless)
+ if err != nil {
+ return nil, err
+ }
+ err = query.AppendStateQuery(domain.MFAStateReady)
+ if err != nil {
+ return nil, err
+ }
+ authMethods, err := s.query.SearchUserAuthMethods(ctx, query, false)
+ authMethods.RemoveNoPermission(ctx, s.checkPermission)
+ if err != nil {
+ return nil, err
+ }
+ return &user.ListPasskeysResponse{
+ Details: object.ToListDetails(authMethods.SearchResponse),
+ Result: authMethodsToPasskeyPb(authMethods),
+ }, nil
+}
+
+func authMethodsToPasskeyPb(methods *query.AuthMethods) []*user.Passkey {
+ t := make([]*user.Passkey, len(methods.AuthMethods))
+ for i, token := range methods.AuthMethods {
+ t[i] = authMethodToPasskeyPb(token)
+ }
+ return t
+}
+
+func authMethodToPasskeyPb(token *query.AuthMethod) *user.Passkey {
+ return &user.Passkey{
+ Id: token.TokenID,
+ State: mfaStateToPb(token.State),
+ Name: token.Name,
+ }
+}
+
+func mfaStateToPb(state domain.MFAState) user.AuthFactorState {
+ switch state {
+ case domain.MFAStateNotReady:
+ return user.AuthFactorState_AUTH_FACTOR_STATE_NOT_READY
+ case domain.MFAStateReady:
+ return user.AuthFactorState_AUTH_FACTOR_STATE_READY
+ case domain.MFAStateUnspecified, domain.MFAStateRemoved:
+ // Handle all remaining cases so the linter succeeds
+ return user.AuthFactorState_AUTH_FACTOR_STATE_UNSPECIFIED
+ default:
+ return user.AuthFactorState_AUTH_FACTOR_STATE_UNSPECIFIED
+ }
+}
diff --git a/internal/api/grpc/user/v2/passkey_integration_test.go b/internal/api/grpc/user/v2/passkey_integration_test.go
index 230a744a64..027005c438 100644
--- a/internal/api/grpc/user/v2/passkey_integration_test.go
+++ b/internal/api/grpc/user/v2/passkey_integration_test.go
@@ -5,6 +5,7 @@ package user_test
import (
"context"
"testing"
+ "time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
@@ -12,9 +13,10 @@ import (
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+
"github.com/zitadel/zitadel/internal/integration"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
)
func TestServer_RegisterPasskey(t *testing.T) {
@@ -138,19 +140,7 @@ func TestServer_RegisterPasskey(t *testing.T) {
}
func TestServer_VerifyPasskeyRegistration(t *testing.T) {
- userID := Tester.CreateHumanUser(CTX).GetUserId()
- reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{
- UserId: userID,
- Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{},
- })
- require.NoError(t, err)
- pkr, err := Client.RegisterPasskey(CTX, &user.RegisterPasskeyRequest{
- UserId: userID,
- Code: reg.GetCode(),
- })
- require.NoError(t, err)
- require.NotEmpty(t, pkr.GetPasskeyId())
- require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions())
+ userID, pkr := userWithPasskeyRegistered(t)
attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions())
require.NoError(t, err)
@@ -317,3 +307,291 @@ func TestServer_CreatePasskeyRegistrationLink(t *testing.T) {
})
}
}
+
+func userWithPasskeyRegistered(t *testing.T) (string, *user.RegisterPasskeyResponse) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ return userID, passkeyRegister(t, userID)
+}
+
+func userWithPasskeyVerified(t *testing.T) (string, string) {
+ userID, pkr := userWithPasskeyRegistered(t)
+ return userID, passkeyVerify(t, userID, pkr)
+}
+
+func passkeyRegister(t *testing.T, userID string) *user.RegisterPasskeyResponse {
+ reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{
+ UserId: userID,
+ Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{},
+ })
+ require.NoError(t, err)
+ pkr, err := Client.RegisterPasskey(CTX, &user.RegisterPasskeyRequest{
+ UserId: userID,
+ Code: reg.GetCode(),
+ })
+ require.NoError(t, err)
+ require.NotEmpty(t, pkr.GetPasskeyId())
+ require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions())
+ return pkr
+}
+
+func passkeyVerify(t *testing.T, userID string, pkr *user.RegisterPasskeyResponse) string {
+ attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions())
+ require.NoError(t, err)
+
+ _, err = Client.VerifyPasskeyRegistration(CTX, &user.VerifyPasskeyRegistrationRequest{
+ UserId: userID,
+ PasskeyId: pkr.GetPasskeyId(),
+ PublicKeyCredential: attestationResponse,
+ PasskeyName: "nice name",
+ })
+ require.NoError(t, err)
+ return pkr.GetPasskeyId()
+}
+
+func TestServer_RemovePasskey(t *testing.T) {
+ userIDWithout := Tester.CreateHumanUser(CTX).GetUserId()
+ userIDRegistered, pkrRegistered := userWithPasskeyRegistered(t)
+ userIDVerified, passkeyIDVerified := userWithPasskeyVerified(t)
+ userIDVerifiedPermission, passkeyIDVerifiedPermission := userWithPasskeyVerified(t)
+
+ type args struct {
+ ctx context.Context
+ req *user.RemovePasskeyRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.RemovePasskeyResponse
+ wantErr bool
+ }{
+ {
+ name: "missing user id",
+ args: args{
+ ctx: IamCTX,
+ req: &user.RemovePasskeyRequest{
+ PasskeyId: "123",
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "missing passkey id",
+ args: args{
+ ctx: IamCTX,
+ req: &user.RemovePasskeyRequest{
+ UserId: "123",
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "success, registered",
+ args: args{
+ ctx: IamCTX,
+ req: &user.RemovePasskeyRequest{
+ UserId: userIDRegistered,
+ PasskeyId: pkrRegistered.GetPasskeyId(),
+ },
+ },
+ want: &user.RemovePasskeyResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "no passkey, error",
+ args: args{
+ ctx: IamCTX,
+ req: &user.RemovePasskeyRequest{
+ UserId: userIDWithout,
+ PasskeyId: pkrRegistered.GetPasskeyId(),
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "success, verified",
+ args: args{
+ ctx: IamCTX,
+ req: &user.RemovePasskeyRequest{
+ UserId: userIDVerified,
+ PasskeyId: passkeyIDVerified,
+ },
+ },
+ want: &user.RemovePasskeyResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "verified, permission error",
+ args: args{
+ ctx: UserCTX,
+ req: &user.RemovePasskeyRequest{
+ UserId: userIDVerifiedPermission,
+ PasskeyId: passkeyIDVerifiedPermission,
+ },
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.RemovePasskey(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_ListPasskeys(t *testing.T) {
+ userIDWithout := Tester.CreateHumanUser(CTX).GetUserId()
+ userIDRegistered, _ := userWithPasskeyRegistered(t)
+ userIDVerified, passkeyIDVerified := userWithPasskeyVerified(t)
+
+ userIDMulti, passkeyIDMulti1 := userWithPasskeyVerified(t)
+ passkeyIDMulti2 := passkeyVerify(t, userIDMulti, passkeyRegister(t, userIDMulti))
+
+ type args struct {
+ ctx context.Context
+ req *user.ListPasskeysRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.ListPasskeysResponse
+ wantErr bool
+ }{
+ {
+ name: "list passkeys, no permission",
+ args: args{
+ UserCTX,
+ &user.ListPasskeysRequest{
+ UserId: userIDVerified,
+ },
+ },
+ want: &user.ListPasskeysResponse{
+ Details: &object.ListDetails{
+ TotalResult: 0,
+ Timestamp: timestamppb.Now(),
+ },
+ Result: []*user.Passkey{},
+ },
+ },
+ {
+ name: "list passkeys, none",
+ args: args{
+ UserCTX,
+ &user.ListPasskeysRequest{
+ UserId: userIDWithout,
+ },
+ },
+ want: &user.ListPasskeysResponse{
+ Details: &object.ListDetails{
+ TotalResult: 0,
+ Timestamp: timestamppb.Now(),
+ },
+ Result: []*user.Passkey{},
+ },
+ },
+ {
+ name: "list passkeys, registered",
+ args: args{
+ UserCTX,
+ &user.ListPasskeysRequest{
+ UserId: userIDRegistered,
+ },
+ },
+ want: &user.ListPasskeysResponse{
+ Details: &object.ListDetails{
+ TotalResult: 0,
+ Timestamp: timestamppb.Now(),
+ },
+ Result: []*user.Passkey{},
+ },
+ },
+ {
+ name: "list passkeys, ok",
+ args: args{
+ IamCTX,
+ &user.ListPasskeysRequest{
+ UserId: userIDVerified,
+ },
+ },
+ want: &user.ListPasskeysResponse{
+ Details: &object.ListDetails{
+ TotalResult: 1,
+ Timestamp: timestamppb.Now(),
+ },
+ Result: []*user.Passkey{
+ {
+ Id: passkeyIDVerified,
+ State: user.AuthFactorState_AUTH_FACTOR_STATE_READY,
+ Name: "nice name",
+ },
+ },
+ },
+ },
+ {
+ name: "list idp links, multi, ok",
+ args: args{
+ IamCTX,
+ &user.ListPasskeysRequest{
+ UserId: userIDMulti,
+ },
+ },
+ want: &user.ListPasskeysResponse{
+ Details: &object.ListDetails{
+ TotalResult: 2,
+ Timestamp: timestamppb.Now(),
+ },
+ Result: []*user.Passkey{
+ {
+ Id: passkeyIDMulti1,
+ State: user.AuthFactorState_AUTH_FACTOR_STATE_READY,
+ Name: "nice name",
+ },
+ {
+ Id: passkeyIDMulti2,
+ State: user.AuthFactorState_AUTH_FACTOR_STATE_READY,
+ Name: "nice name",
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ retryDuration := time.Minute
+ if ctxDeadline, ok := CTX.Deadline(); ok {
+ retryDuration = time.Until(ctxDeadline)
+ }
+ require.EventuallyWithT(t, func(ttt *assert.CollectT) {
+ got, listErr := Client.ListPasskeys(tt.args.ctx, tt.args.req)
+ assertErr := assert.NoError
+ if tt.wantErr {
+ assertErr = assert.Error
+ }
+ assertErr(ttt, listErr)
+ if listErr != nil {
+ return
+ }
+ // always first check length, otherwise its failed anyway
+ assert.Len(ttt, got.Result, len(tt.want.Result))
+ for i := range tt.want.Result {
+ assert.Contains(ttt, got.Result, tt.want.Result[i])
+ }
+ integration.AssertListDetails(t, tt.want, got)
+ }, retryDuration, time.Millisecond*100, "timeout waiting for expected idplinks result")
+ })
+ }
+}
diff --git a/internal/api/grpc/user/v2/passkey_test.go b/internal/api/grpc/user/v2/passkey_test.go
index 7d45c41756..7facfc74e0 100644
--- a/internal/api/grpc/user/v2/passkey_test.go
+++ b/internal/api/grpc/user/v2/passkey_test.go
@@ -14,8 +14,8 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/zerrors"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func Test_passkeyAuthenticatorToDomain(t *testing.T) {
diff --git a/internal/api/grpc/user/v2/password.go b/internal/api/grpc/user/v2/password.go
index a7d6a55f2e..55cf225c4b 100644
--- a/internal/api/grpc/user/v2/password.go
+++ b/internal/api/grpc/user/v2/password.go
@@ -6,7 +6,7 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/zerrors"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetRequest) (_ *user.PasswordResetResponse, err error) {
diff --git a/internal/api/grpc/user/v2/password_integration_test.go b/internal/api/grpc/user/v2/password_integration_test.go
index 03b18a5fa7..f97d0467d7 100644
--- a/internal/api/grpc/user/v2/password_integration_test.go
+++ b/internal/api/grpc/user/v2/password_integration_test.go
@@ -11,9 +11,10 @@ import (
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+
"github.com/zitadel/zitadel/internal/integration"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
)
func TestServer_RequestPasswordReset(t *testing.T) {
diff --git a/internal/api/grpc/user/v2/password_test.go b/internal/api/grpc/user/v2/password_test.go
index 5ce9930b39..f3c35b090b 100644
--- a/internal/api/grpc/user/v2/password_test.go
+++ b/internal/api/grpc/user/v2/password_test.go
@@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/zitadel/zitadel/internal/domain"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func Test_notificationTypeToDomain(t *testing.T) {
diff --git a/internal/api/grpc/user/v2/phone.go b/internal/api/grpc/user/v2/phone.go
index eac7eb4e31..fdd5a140c1 100644
--- a/internal/api/grpc/user/v2/phone.go
+++ b/internal/api/grpc/user/v2/phone.go
@@ -7,8 +7,8 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/zerrors"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp *user.SetPhoneResponse, err error) {
diff --git a/internal/api/grpc/user/v2/phone_integration_test.go b/internal/api/grpc/user/v2/phone_integration_test.go
index e2c670c6bd..d67b59d0b4 100644
--- a/internal/api/grpc/user/v2/phone_integration_test.go
+++ b/internal/api/grpc/user/v2/phone_integration_test.go
@@ -13,9 +13,10 @@ import (
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+
"github.com/zitadel/zitadel/internal/integration"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
)
func TestServer_SetPhone(t *testing.T) {
diff --git a/internal/api/grpc/user/v2/query.go b/internal/api/grpc/user/v2/query.go
index 2aaf0ad8ef..95262a66ae 100644
--- a/internal/api/grpc/user/v2/query.go
+++ b/internal/api/grpc/user/v2/query.go
@@ -11,7 +11,7 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) (_ *user.GetUserByIDResponse, err error) {
diff --git a/internal/api/grpc/user/v2/query_integration_test.go b/internal/api/grpc/user/v2/query_integration_test.go
index b4b770481b..f7fcf8fbe5 100644
--- a/internal/api/grpc/user/v2/query_integration_test.go
+++ b/internal/api/grpc/user/v2/query_integration_test.go
@@ -13,9 +13,10 @@ import (
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+
"github.com/zitadel/zitadel/internal/integration"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
)
func TestServer_GetUserByID(t *testing.T) {
diff --git a/internal/api/grpc/user/v2/server.go b/internal/api/grpc/user/v2/server.go
index 93af47f58b..9272ea27ee 100644
--- a/internal/api/grpc/user/v2/server.go
+++ b/internal/api/grpc/user/v2/server.go
@@ -11,7 +11,7 @@ import (
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
var _ user.UserServiceServer = (*Server)(nil)
diff --git a/internal/api/grpc/user/v2/totp.go b/internal/api/grpc/user/v2/totp.go
index e426f5788d..9e2d028d72 100644
--- a/internal/api/grpc/user/v2/totp.go
+++ b/internal/api/grpc/user/v2/totp.go
@@ -5,7 +5,7 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/domain"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func (s *Server) RegisterTOTP(ctx context.Context, req *user.RegisterTOTPRequest) (*user.RegisterTOTPResponse, error) {
diff --git a/internal/api/grpc/user/v2/totp_integration_test.go b/internal/api/grpc/user/v2/totp_integration_test.go
index 489c189b03..474aed95b8 100644
--- a/internal/api/grpc/user/v2/totp_integration_test.go
+++ b/internal/api/grpc/user/v2/totp_integration_test.go
@@ -12,9 +12,10 @@ import (
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+
"github.com/zitadel/zitadel/internal/integration"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
)
func TestServer_RegisterTOTP(t *testing.T) {
diff --git a/internal/api/grpc/user/v2/totp_test.go b/internal/api/grpc/user/v2/totp_test.go
index 81a54675f2..27ce6fb469 100644
--- a/internal/api/grpc/user/v2/totp_test.go
+++ b/internal/api/grpc/user/v2/totp_test.go
@@ -10,8 +10,8 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/domain"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func Test_totpDetailsToPb(t *testing.T) {
diff --git a/internal/api/grpc/user/v2/u2f.go b/internal/api/grpc/user/v2/u2f.go
index f13d21736e..60c0f5ab07 100644
--- a/internal/api/grpc/user/v2/u2f.go
+++ b/internal/api/grpc/user/v2/u2f.go
@@ -6,7 +6,7 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/zerrors"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func (s *Server) RegisterU2F(ctx context.Context, req *user.RegisterU2FRequest) (*user.RegisterU2FResponse, error) {
@@ -40,3 +40,13 @@ func (s *Server) VerifyU2FRegistration(ctx context.Context, req *user.VerifyU2FR
Details: object.DomainToDetailsPb(objectDetails),
}, nil
}
+
+func (s *Server) RemoveU2F(ctx context.Context, req *user.RemoveU2FRequest) (*user.RemoveU2FResponse, error) {
+ objectDetails, err := s.command.HumanRemoveU2F(ctx, req.GetUserId(), req.GetU2FId(), "")
+ if err != nil {
+ return nil, err
+ }
+ return &user.RemoveU2FResponse{
+ Details: object.DomainToDetailsPb(objectDetails),
+ }, nil
+}
diff --git a/internal/api/grpc/user/v2/u2f_integration_test.go b/internal/api/grpc/user/v2/u2f_integration_test.go
index 3b7fbd293c..c4d4c33071 100644
--- a/internal/api/grpc/user/v2/u2f_integration_test.go
+++ b/internal/api/grpc/user/v2/u2f_integration_test.go
@@ -11,9 +11,10 @@ import (
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+
"github.com/zitadel/zitadel/internal/integration"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
)
func TestServer_RegisterU2F(t *testing.T) {
@@ -106,16 +107,7 @@ func TestServer_RegisterU2F(t *testing.T) {
}
func TestServer_VerifyU2FRegistration(t *testing.T) {
- userID := Tester.CreateHumanUser(CTX).GetUserId()
- Tester.RegisterUserPasskey(CTX, userID)
- _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID)
- ctx := Tester.WithAuthorizationToken(CTX, sessionToken)
-
- pkr, err := Client.RegisterU2F(ctx, &user.RegisterU2FRequest{
- UserId: userID,
- })
- require.NoError(t, err)
- require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions())
+ ctx, userID, pkr := ctxFromNewUserWithRegisteredU2F(t)
attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions())
require.NoError(t, err)
@@ -188,3 +180,138 @@ func TestServer_VerifyU2FRegistration(t *testing.T) {
})
}
}
+
+func ctxFromNewUserWithRegisteredU2F(t *testing.T) (context.Context, string, *user.RegisterU2FResponse) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ Tester.RegisterUserPasskey(CTX, userID)
+ _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID)
+ ctx := Tester.WithAuthorizationToken(CTX, sessionToken)
+
+ pkr, err := Client.RegisterU2F(ctx, &user.RegisterU2FRequest{
+ UserId: userID,
+ })
+ require.NoError(t, err)
+ require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions())
+ return ctx, userID, pkr
+}
+
+func ctxFromNewUserWithVerifiedU2F(t *testing.T) (context.Context, string, string) {
+ ctx, userID, pkr := ctxFromNewUserWithRegisteredU2F(t)
+
+ attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions())
+ require.NoError(t, err)
+
+ _, err = Client.VerifyU2FRegistration(ctx, &user.VerifyU2FRegistrationRequest{
+ UserId: userID,
+ U2FId: pkr.GetU2FId(),
+ PublicKeyCredential: attestationResponse,
+ TokenName: "nice name",
+ })
+ require.NoError(t, err)
+ return ctx, userID, pkr.GetU2FId()
+}
+
+func TestServer_RemoveU2F(t *testing.T) {
+ userIDWithout := Tester.CreateHumanUser(CTX).GetUserId()
+ ctxRegistered, userIDRegistered, pkrRegistered := ctxFromNewUserWithRegisteredU2F(t)
+ _, userIDVerified, u2fVerified := ctxFromNewUserWithVerifiedU2F(t)
+ _, userIDVerifiedPermission, u2fVerifiedPermission := ctxFromNewUserWithVerifiedU2F(t)
+
+ type args struct {
+ ctx context.Context
+ req *user.RemoveU2FRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.RemoveU2FResponse
+ wantErr bool
+ }{
+ {
+ name: "missing user id",
+ args: args{
+ ctx: ctxRegistered,
+ req: &user.RemoveU2FRequest{
+ U2FId: "123",
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "missing u2f id",
+ args: args{
+ ctx: ctxRegistered,
+ req: &user.RemoveU2FRequest{
+ UserId: "123",
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "success, registered",
+ args: args{
+ ctx: ctxRegistered,
+ req: &user.RemoveU2FRequest{
+ UserId: userIDRegistered,
+ U2FId: pkrRegistered.GetU2FId(),
+ },
+ },
+ want: &user.RemoveU2FResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "no u2f, error",
+ args: args{
+ ctx: IamCTX,
+ req: &user.RemoveU2FRequest{
+ UserId: userIDWithout,
+ U2FId: pkrRegistered.GetU2FId(),
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "success, IAMOwner permission, verified",
+ args: args{
+ ctx: IamCTX,
+ req: &user.RemoveU2FRequest{
+ UserId: userIDVerified,
+ U2FId: u2fVerified,
+ },
+ },
+ want: &user.RemoveU2FResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "verified, permission error",
+ args: args{
+ ctx: UserCTX,
+ req: &user.RemoveU2FRequest{
+ UserId: userIDVerifiedPermission,
+ U2FId: u2fVerifiedPermission,
+ },
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.RemoveU2F(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
diff --git a/internal/api/grpc/user/v2/u2f_test.go b/internal/api/grpc/user/v2/u2f_test.go
index 087837ce3c..73366ab29b 100644
--- a/internal/api/grpc/user/v2/u2f_test.go
+++ b/internal/api/grpc/user/v2/u2f_test.go
@@ -13,8 +13,8 @@ import (
"github.com/zitadel/zitadel/internal/api/grpc"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/zerrors"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func Test_u2fRegistrationDetailsToPb(t *testing.T) {
diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go
index dd96f3107a..9ad83dfea3 100644
--- a/internal/api/grpc/user/v2/user.go
+++ b/internal/api/grpc/user/v2/user.go
@@ -18,11 +18,12 @@ import (
"github.com/zitadel/zitadel/internal/idp/providers/ldap"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
- object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) {
+
human, err := AddUserRequestToAddHuman(req)
if err != nil {
return nil, err
@@ -259,20 +260,6 @@ func SetHumanPasswordToPassword(password *user.SetPassword) *command.Password {
}
}
-func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ *user.AddIDPLinkResponse, err error) {
- details, err := s.command.AddUserIDPLink(ctx, req.UserId, "", &command.AddLink{
- IDPID: req.GetIdpLink().GetIdpId(),
- DisplayName: req.GetIdpLink().GetUserName(),
- IDPExternalID: req.GetIdpLink().GetUserId(),
- })
- if err != nil {
- return nil, err
- }
- return &user.AddIDPLinkResponse{
- Details: object.DomainToDetailsPb(details),
- }, nil
-}
-
func (s *Server) DeleteUser(ctx context.Context, req *user.DeleteUserRequest) (_ *user.DeleteUserResponse, err error) {
memberships, grants, err := s.removeUserDependencies(ctx, req.GetUserId())
if err != nil {
diff --git a/internal/api/grpc/user/v2/user_integration_test.go b/internal/api/grpc/user/v2/user_integration_test.go
index 824029724e..e762c10181 100644
--- a/internal/api/grpc/user/v2/user_integration_test.go
+++ b/internal/api/grpc/user/v2/user_integration_test.go
@@ -18,12 +18,13 @@ import (
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+
"github.com/zitadel/zitadel/internal/api/grpc"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/idp"
mgmt "github.com/zitadel/zitadel/pkg/grpc/management"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
)
var (
@@ -1797,86 +1798,6 @@ func TestServer_DeleteUser(t *testing.T) {
}
}
-func TestServer_AddIDPLink(t *testing.T) {
- idpID := Tester.AddGenericOAuthProvider(t, CTX)
- type args struct {
- ctx context.Context
- req *user.AddIDPLinkRequest
- }
- tests := []struct {
- name string
- args args
- want *user.AddIDPLinkResponse
- wantErr bool
- }{
- {
- name: "user does not exist",
- args: args{
- CTX,
- &user.AddIDPLinkRequest{
- UserId: "userID",
- IdpLink: &user.IDPLink{
- IdpId: idpID,
- UserId: "userID",
- UserName: "username",
- },
- },
- },
- want: nil,
- wantErr: true,
- },
- {
- name: "idp does not exist",
- args: args{
- CTX,
- &user.AddIDPLinkRequest{
- UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID,
- IdpLink: &user.IDPLink{
- IdpId: "idpID",
- UserId: "userID",
- UserName: "username",
- },
- },
- },
- want: nil,
- wantErr: true,
- },
- {
- name: "add link",
- args: args{
- CTX,
- &user.AddIDPLinkRequest{
- UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID,
- IdpLink: &user.IDPLink{
- IdpId: idpID,
- UserId: "userID",
- UserName: "username",
- },
- },
- },
- want: &user.AddIDPLinkResponse{
- Details: &object.Details{
- ChangeDate: timestamppb.Now(),
- ResourceOwner: Tester.Organisation.ID,
- },
- },
- wantErr: false,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := Client.AddIDPLink(tt.args.ctx, tt.args.req)
- if tt.wantErr {
- require.Error(t, err)
- } else {
- require.NoError(t, err)
- }
-
- integration.AssertDetails(t, tt.want, got)
- })
- }
-}
-
func TestServer_StartIdentityProviderIntent(t *testing.T) {
idpID := Tester.AddGenericOAuthProvider(t, CTX)
orgIdpID := Tester.AddOrgGenericOAuthProvider(t, CTX, Tester.Organisation.ID)
diff --git a/internal/api/grpc/user/v2/user_test.go b/internal/api/grpc/user/v2/user_test.go
index 9e398e83ff..9e7a5a5ab0 100644
--- a/internal/api/grpc/user/v2/user_test.go
+++ b/internal/api/grpc/user/v2/user_test.go
@@ -17,8 +17,8 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/zerrors"
- object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func Test_idpIntentToIDPIntentPb(t *testing.T) {
diff --git a/internal/api/grpc/user/v2beta/email.go b/internal/api/grpc/user/v2beta/email.go
new file mode 100644
index 0000000000..38cc73c75c
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/email.go
@@ -0,0 +1,86 @@
+package user
+
+import (
+ "context"
+
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/zerrors"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp *user.SetEmailResponse, err error) {
+ var email *domain.Email
+
+ switch v := req.GetVerification().(type) {
+ case *user.SetEmailRequest_SendCode:
+ email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate())
+ case *user.SetEmailRequest_ReturnCode:
+ email, err = s.command.ChangeUserEmailReturnCode(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg)
+ case *user.SetEmailRequest_IsVerified:
+ email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), req.GetEmail())
+ case nil:
+ email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg)
+ default:
+ err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetEmail not implemented", v)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &user.SetEmailResponse{
+ Details: &object.Details{
+ Sequence: email.Sequence,
+ ChangeDate: timestamppb.New(email.ChangeDate),
+ ResourceOwner: email.ResourceOwner,
+ },
+ VerificationCode: email.PlainCode,
+ }, nil
+}
+
+func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeRequest) (resp *user.ResendEmailCodeResponse, err error) {
+ var email *domain.Email
+
+ switch v := req.GetVerification().(type) {
+ case *user.ResendEmailCodeRequest_SendCode:
+ email, err = s.command.ResendUserEmailCodeURLTemplate(ctx, req.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate())
+ case *user.ResendEmailCodeRequest_ReturnCode:
+ email, err = s.command.ResendUserEmailReturnCode(ctx, req.GetUserId(), s.userCodeAlg)
+ case nil:
+ email, err = s.command.ResendUserEmailCode(ctx, req.GetUserId(), s.userCodeAlg)
+ default:
+ err = zerrors.ThrowUnimplementedf(nil, "USERv2-faj0l0nj5x", "verification oneOf %T in method ResendEmailCode not implemented", v)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &user.ResendEmailCodeResponse{
+ Details: &object.Details{
+ Sequence: email.Sequence,
+ ChangeDate: timestamppb.New(email.ChangeDate),
+ ResourceOwner: email.ResourceOwner,
+ },
+ VerificationCode: email.PlainCode,
+ }, nil
+}
+
+func (s *Server) VerifyEmail(ctx context.Context, req *user.VerifyEmailRequest) (*user.VerifyEmailResponse, error) {
+ details, err := s.command.VerifyUserEmail(ctx,
+ req.GetUserId(),
+ req.GetVerificationCode(),
+ s.userCodeAlg,
+ )
+ if err != nil {
+ return nil, err
+ }
+ return &user.VerifyEmailResponse{
+ Details: &object.Details{
+ Sequence: details.Sequence,
+ ChangeDate: timestamppb.New(details.EventDate),
+ ResourceOwner: details.ResourceOwner,
+ },
+ }, nil
+}
diff --git a/internal/api/grpc/user/v2beta/email_integration_test.go b/internal/api/grpc/user/v2beta/email_integration_test.go
new file mode 100644
index 0000000000..4034a5e7da
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/email_integration_test.go
@@ -0,0 +1,297 @@
+//go:build integration
+
+package user_test
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/integration"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func TestServer_SetEmail(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+
+ tests := []struct {
+ name string
+ req *user.SetEmailRequest
+ want *user.SetEmailResponse
+ wantErr bool
+ }{
+ {
+ name: "user not existing",
+ req: &user.SetEmailRequest{
+ UserId: "xxx",
+ Email: "default-verifier@mouse.com",
+ },
+ wantErr: true,
+ },
+ {
+ name: "default verfication",
+ req: &user.SetEmailRequest{
+ UserId: userID,
+ Email: "default-verifier@mouse.com",
+ },
+ want: &user.SetEmailResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "custom url template",
+ req: &user.SetEmailRequest{
+ UserId: userID,
+ Email: "custom-url@mouse.com",
+ Verification: &user.SetEmailRequest_SendCode{
+ SendCode: &user.SendEmailVerificationCode{
+ UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"),
+ },
+ },
+ },
+ want: &user.SetEmailResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "template error",
+ req: &user.SetEmailRequest{
+ UserId: userID,
+ Email: "custom-url@mouse.com",
+ Verification: &user.SetEmailRequest_SendCode{
+ SendCode: &user.SendEmailVerificationCode{
+ UrlTemplate: gu.Ptr("{{"),
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "return code",
+ req: &user.SetEmailRequest{
+ UserId: userID,
+ Email: "return-code@mouse.com",
+ Verification: &user.SetEmailRequest_ReturnCode{
+ ReturnCode: &user.ReturnEmailVerificationCode{},
+ },
+ },
+ want: &user.SetEmailResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ VerificationCode: gu.Ptr("xxx"),
+ },
+ },
+ {
+ name: "is verified true",
+ req: &user.SetEmailRequest{
+ UserId: userID,
+ Email: "verified-true@mouse.com",
+ Verification: &user.SetEmailRequest_IsVerified{
+ IsVerified: true,
+ },
+ },
+ want: &user.SetEmailResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "is verified false",
+ req: &user.SetEmailRequest{
+ UserId: userID,
+ Email: "verified-false@mouse.com",
+ Verification: &user.SetEmailRequest_IsVerified{
+ IsVerified: false,
+ },
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.SetEmail(CTX, tt.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ integration.AssertDetails(t, tt.want, got)
+ if tt.want.GetVerificationCode() != "" {
+ assert.NotEmpty(t, got.GetVerificationCode())
+ }
+ })
+ }
+}
+
+func TestServer_ResendEmailCode(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ verifiedUserID := Tester.CreateHumanUserVerified(CTX, Tester.Organisation.ID, fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())).GetUserId()
+
+ tests := []struct {
+ name string
+ req *user.ResendEmailCodeRequest
+ want *user.ResendEmailCodeResponse
+ wantErr bool
+ }{
+ {
+ name: "user not existing",
+ req: &user.ResendEmailCodeRequest{
+ UserId: "xxx",
+ },
+ wantErr: true,
+ },
+ {
+ name: "user no code",
+ req: &user.ResendEmailCodeRequest{
+ UserId: verifiedUserID,
+ },
+ wantErr: true,
+ },
+ {
+ name: "resend",
+ req: &user.ResendEmailCodeRequest{
+ UserId: userID,
+ },
+ want: &user.ResendEmailCodeResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "custom url template",
+ req: &user.ResendEmailCodeRequest{
+ UserId: userID,
+ Verification: &user.ResendEmailCodeRequest_SendCode{
+ SendCode: &user.SendEmailVerificationCode{
+ UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"),
+ },
+ },
+ },
+ want: &user.ResendEmailCodeResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "template error",
+ req: &user.ResendEmailCodeRequest{
+ UserId: userID,
+ Verification: &user.ResendEmailCodeRequest_SendCode{
+ SendCode: &user.SendEmailVerificationCode{
+ UrlTemplate: gu.Ptr("{{"),
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "return code",
+ req: &user.ResendEmailCodeRequest{
+ UserId: userID,
+ Verification: &user.ResendEmailCodeRequest_ReturnCode{
+ ReturnCode: &user.ReturnEmailVerificationCode{},
+ },
+ },
+ want: &user.ResendEmailCodeResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ VerificationCode: gu.Ptr("xxx"),
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.ResendEmailCode(CTX, tt.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ integration.AssertDetails(t, tt.want, got)
+ if tt.want.GetVerificationCode() != "" {
+ assert.NotEmpty(t, got.GetVerificationCode())
+ }
+ })
+ }
+}
+
+func TestServer_VerifyEmail(t *testing.T) {
+ userResp := Tester.CreateHumanUser(CTX)
+ tests := []struct {
+ name string
+ req *user.VerifyEmailRequest
+ want *user.VerifyEmailResponse
+ wantErr bool
+ }{
+ {
+ name: "wrong code",
+ req: &user.VerifyEmailRequest{
+ UserId: userResp.GetUserId(),
+ VerificationCode: "xxx",
+ },
+ wantErr: true,
+ },
+ {
+ name: "wrong user",
+ req: &user.VerifyEmailRequest{
+ UserId: "xxx",
+ VerificationCode: userResp.GetEmailCode(),
+ },
+ wantErr: true,
+ },
+ {
+ name: "verify user",
+ req: &user.VerifyEmailRequest{
+ UserId: userResp.GetUserId(),
+ VerificationCode: userResp.GetEmailCode(),
+ },
+ want: &user.VerifyEmailResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.VerifyEmail(CTX, tt.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
diff --git a/internal/api/grpc/user/v2beta/otp.go b/internal/api/grpc/user/v2beta/otp.go
new file mode 100644
index 0000000000..c11aa4c1a4
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/otp.go
@@ -0,0 +1,42 @@
+package user
+
+import (
+ "context"
+
+ object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func (s *Server) AddOTPSMS(ctx context.Context, req *user.AddOTPSMSRequest) (*user.AddOTPSMSResponse, error) {
+ details, err := s.command.AddHumanOTPSMS(ctx, req.GetUserId(), "")
+ if err != nil {
+ return nil, err
+ }
+ return &user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}, nil
+
+}
+
+func (s *Server) RemoveOTPSMS(ctx context.Context, req *user.RemoveOTPSMSRequest) (*user.RemoveOTPSMSResponse, error) {
+ objectDetails, err := s.command.RemoveHumanOTPSMS(ctx, req.GetUserId(), "")
+ if err != nil {
+ return nil, err
+ }
+ return &user.RemoveOTPSMSResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil
+}
+
+func (s *Server) AddOTPEmail(ctx context.Context, req *user.AddOTPEmailRequest) (*user.AddOTPEmailResponse, error) {
+ details, err := s.command.AddHumanOTPEmail(ctx, req.GetUserId(), "")
+ if err != nil {
+ return nil, err
+ }
+ return &user.AddOTPEmailResponse{Details: object.DomainToDetailsPb(details)}, nil
+
+}
+
+func (s *Server) RemoveOTPEmail(ctx context.Context, req *user.RemoveOTPEmailRequest) (*user.RemoveOTPEmailResponse, error) {
+ objectDetails, err := s.command.RemoveHumanOTPEmail(ctx, req.GetUserId(), "")
+ if err != nil {
+ return nil, err
+ }
+ return &user.RemoveOTPEmailResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil
+}
diff --git a/internal/api/grpc/user/v2beta/otp_integration_test.go b/internal/api/grpc/user/v2beta/otp_integration_test.go
new file mode 100644
index 0000000000..a6d671c645
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/otp_integration_test.go
@@ -0,0 +1,362 @@
+//go:build integration
+
+package user_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/integration"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func TestServer_AddOTPSMS(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ Tester.RegisterUserPasskey(CTX, userID)
+ _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID)
+
+ otherUser := Tester.CreateHumanUser(CTX).GetUserId()
+ Tester.RegisterUserPasskey(CTX, otherUser)
+ _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser)
+
+ userVerified := Tester.CreateHumanUser(CTX)
+ _, err := Client.VerifyPhone(CTX, &user.VerifyPhoneRequest{
+ UserId: userVerified.GetUserId(),
+ VerificationCode: userVerified.GetPhoneCode(),
+ })
+ require.NoError(t, err)
+ Tester.RegisterUserPasskey(CTX, userVerified.GetUserId())
+ _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId())
+
+ userVerified2 := Tester.CreateHumanUser(CTX)
+ _, err = Client.VerifyPhone(CTX, &user.VerifyPhoneRequest{
+ UserId: userVerified2.GetUserId(),
+ VerificationCode: userVerified2.GetPhoneCode(),
+ })
+ require.NoError(t, err)
+
+ type args struct {
+ ctx context.Context
+ req *user.AddOTPSMSRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.AddOTPSMSResponse
+ wantErr bool
+ }{
+ {
+ name: "missing user id",
+ args: args{
+ ctx: CTX,
+ req: &user.AddOTPSMSRequest{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "user mismatch",
+ args: args{
+ ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenOtherUser),
+ req: &user.AddOTPSMSRequest{
+ UserId: userID,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "phone not verified",
+ args: args{
+ ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken),
+ req: &user.AddOTPSMSRequest{
+ UserId: userID,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "add success",
+ args: args{
+ ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified),
+ req: &user.AddOTPSMSRequest{
+ UserId: userVerified.GetUserId(),
+ },
+ },
+ want: &user.AddOTPSMSResponse{
+ Details: &object.Details{
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "add success, admin",
+ args: args{
+ ctx: CTX,
+ req: &user.AddOTPSMSRequest{
+ UserId: userVerified2.GetUserId(),
+ },
+ },
+ want: &user.AddOTPSMSResponse{
+ Details: &object.Details{
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.AddOTPSMS(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_RemoveOTPSMS(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ Tester.RegisterUserPasskey(CTX, userID)
+ _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID)
+
+ userVerified := Tester.CreateHumanUser(CTX)
+ Tester.RegisterUserPasskey(CTX, userVerified.GetUserId())
+ _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId())
+ userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified)
+ _, err := Client.VerifyPhone(userVerifiedCtx, &user.VerifyPhoneRequest{
+ UserId: userVerified.GetUserId(),
+ VerificationCode: userVerified.GetPhoneCode(),
+ })
+ require.NoError(t, err)
+ _, err = Client.AddOTPSMS(userVerifiedCtx, &user.AddOTPSMSRequest{UserId: userVerified.GetUserId()})
+ require.NoError(t, err)
+
+ type args struct {
+ ctx context.Context
+ req *user.RemoveOTPSMSRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.RemoveOTPSMSResponse
+ wantErr bool
+ }{
+ {
+ name: "not added",
+ args: args{
+ ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken),
+ req: &user.RemoveOTPSMSRequest{
+ UserId: userID,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "success",
+ args: args{
+ ctx: userVerifiedCtx,
+ req: &user.RemoveOTPSMSRequest{
+ UserId: userVerified.GetUserId(),
+ },
+ },
+ want: &user.RemoveOTPSMSResponse{
+ Details: &object.Details{
+ ResourceOwner: Tester.Organisation.ResourceOwner,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.RemoveOTPSMS(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_AddOTPEmail(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ Tester.RegisterUserPasskey(CTX, userID)
+ _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID)
+
+ otherUser := Tester.CreateHumanUser(CTX).GetUserId()
+ Tester.RegisterUserPasskey(CTX, otherUser)
+ _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser)
+
+ userVerified := Tester.CreateHumanUser(CTX)
+ _, err := Client.VerifyEmail(CTX, &user.VerifyEmailRequest{
+ UserId: userVerified.GetUserId(),
+ VerificationCode: userVerified.GetEmailCode(),
+ })
+ require.NoError(t, err)
+ Tester.RegisterUserPasskey(CTX, userVerified.GetUserId())
+ _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId())
+
+ userVerified2 := Tester.CreateHumanUser(CTX)
+ _, err = Client.VerifyEmail(CTX, &user.VerifyEmailRequest{
+ UserId: userVerified2.GetUserId(),
+ VerificationCode: userVerified2.GetEmailCode(),
+ })
+ require.NoError(t, err)
+
+ type args struct {
+ ctx context.Context
+ req *user.AddOTPEmailRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.AddOTPEmailResponse
+ wantErr bool
+ }{
+ {
+ name: "missing user id",
+ args: args{
+ ctx: CTX,
+ req: &user.AddOTPEmailRequest{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "user mismatch",
+ args: args{
+ ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenOtherUser),
+ req: &user.AddOTPEmailRequest{
+ UserId: userID,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "email not verified",
+ args: args{
+ ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken),
+ req: &user.AddOTPEmailRequest{
+ UserId: userID,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "add success",
+ args: args{
+ ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified),
+ req: &user.AddOTPEmailRequest{
+ UserId: userVerified.GetUserId(),
+ },
+ },
+ want: &user.AddOTPEmailResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "add success, admin",
+ args: args{
+ ctx: CTX,
+ req: &user.AddOTPEmailRequest{
+ UserId: userVerified2.GetUserId(),
+ },
+ },
+ want: &user.AddOTPEmailResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.AddOTPEmail(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_RemoveOTPEmail(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ Tester.RegisterUserPasskey(CTX, userID)
+ _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID)
+
+ userVerified := Tester.CreateHumanUser(CTX)
+ Tester.RegisterUserPasskey(CTX, userVerified.GetUserId())
+ _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId())
+ userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified)
+ _, err := Client.VerifyEmail(userVerifiedCtx, &user.VerifyEmailRequest{
+ UserId: userVerified.GetUserId(),
+ VerificationCode: userVerified.GetEmailCode(),
+ })
+ require.NoError(t, err)
+ _, err = Client.AddOTPEmail(userVerifiedCtx, &user.AddOTPEmailRequest{UserId: userVerified.GetUserId()})
+ require.NoError(t, err)
+
+ type args struct {
+ ctx context.Context
+ req *user.RemoveOTPEmailRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.RemoveOTPEmailResponse
+ wantErr bool
+ }{
+ {
+ name: "not added",
+ args: args{
+ ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken),
+ req: &user.RemoveOTPEmailRequest{
+ UserId: userID,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "success",
+ args: args{
+ ctx: userVerifiedCtx,
+ req: &user.RemoveOTPEmailRequest{
+ UserId: userVerified.GetUserId(),
+ },
+ },
+ want: &user.RemoveOTPEmailResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ResourceOwner,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.RemoveOTPEmail(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
diff --git a/internal/api/grpc/user/v2beta/passkey.go b/internal/api/grpc/user/v2beta/passkey.go
new file mode 100644
index 0000000000..2df267f3fd
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/passkey.go
@@ -0,0 +1,118 @@
+package user
+
+import (
+ "context"
+
+ "google.golang.org/protobuf/types/known/structpb"
+
+ object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/zerrors"
+ object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func (s *Server) RegisterPasskey(ctx context.Context, req *user.RegisterPasskeyRequest) (resp *user.RegisterPasskeyResponse, err error) {
+ var (
+ authenticator = passkeyAuthenticatorToDomain(req.GetAuthenticator())
+ )
+ if code := req.GetCode(); code != nil {
+ return passkeyRegistrationDetailsToPb(
+ s.command.RegisterUserPasskeyWithCode(ctx, req.GetUserId(), "", authenticator, code.Id, code.Code, req.GetDomain(), s.userCodeAlg),
+ )
+ }
+ return passkeyRegistrationDetailsToPb(
+ s.command.RegisterUserPasskey(ctx, req.GetUserId(), "", req.GetDomain(), authenticator),
+ )
+}
+
+func passkeyAuthenticatorToDomain(pa user.PasskeyAuthenticator) domain.AuthenticatorAttachment {
+ switch pa {
+ case user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_UNSPECIFIED:
+ return domain.AuthenticatorAttachmentUnspecified
+ case user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM:
+ return domain.AuthenticatorAttachmentPlattform
+ case user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_CROSS_PLATFORM:
+ return domain.AuthenticatorAttachmentCrossPlattform
+ default:
+ return domain.AuthenticatorAttachmentUnspecified
+ }
+}
+
+func webAuthNRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*object_pb.Details, *structpb.Struct, error) {
+ if err != nil {
+ return nil, nil, err
+ }
+ options := new(structpb.Struct)
+ if err := options.UnmarshalJSON(details.PublicKeyCredentialCreationOptions); err != nil {
+ return nil, nil, zerrors.ThrowInternal(err, "USERv2-Dohr6", "Errors.Internal")
+ }
+ return object.DomainToDetailsPb(details.ObjectDetails), options, nil
+}
+
+func passkeyRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterPasskeyResponse, error) {
+ objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err)
+ if err != nil {
+ return nil, err
+ }
+ return &user.RegisterPasskeyResponse{
+ Details: objectDetails,
+ PasskeyId: details.ID,
+ PublicKeyCredentialCreationOptions: options,
+ }, nil
+}
+
+func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *user.VerifyPasskeyRegistrationRequest) (*user.VerifyPasskeyRegistrationResponse, error) {
+ pkc, err := req.GetPublicKeyCredential().MarshalJSON()
+ if err != nil {
+ return nil, zerrors.ThrowInternal(err, "USERv2-Pha2o", "Errors.Internal")
+ }
+ objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.GetUserId(), "", req.GetPasskeyName(), "", pkc)
+ if err != nil {
+ return nil, err
+ }
+ return &user.VerifyPasskeyRegistrationResponse{
+ Details: object.DomainToDetailsPb(objectDetails),
+ }, nil
+}
+
+func (s *Server) CreatePasskeyRegistrationLink(ctx context.Context, req *user.CreatePasskeyRegistrationLinkRequest) (resp *user.CreatePasskeyRegistrationLinkResponse, err error) {
+ switch medium := req.Medium.(type) {
+ case nil:
+ return passkeyDetailsToPb(
+ s.command.AddUserPasskeyCode(ctx, req.GetUserId(), "", s.userCodeAlg),
+ )
+ case *user.CreatePasskeyRegistrationLinkRequest_SendLink:
+ return passkeyDetailsToPb(
+ s.command.AddUserPasskeyCodeURLTemplate(ctx, req.GetUserId(), "", s.userCodeAlg, medium.SendLink.GetUrlTemplate()),
+ )
+ case *user.CreatePasskeyRegistrationLinkRequest_ReturnCode:
+ return passkeyCodeDetailsToPb(
+ s.command.AddUserPasskeyCodeReturn(ctx, req.GetUserId(), "", s.userCodeAlg),
+ )
+ default:
+ return nil, zerrors.ThrowUnimplementedf(nil, "USERv2-gaD8y", "verification oneOf %T in method CreatePasskeyRegistrationLink not implemented", medium)
+ }
+}
+
+func passkeyDetailsToPb(details *domain.ObjectDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) {
+ if err != nil {
+ return nil, err
+ }
+ return &user.CreatePasskeyRegistrationLinkResponse{
+ Details: object.DomainToDetailsPb(details),
+ }, nil
+}
+
+func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) {
+ if err != nil {
+ return nil, err
+ }
+ return &user.CreatePasskeyRegistrationLinkResponse{
+ Details: object.DomainToDetailsPb(details.ObjectDetails),
+ Code: &user.PasskeyRegistrationCode{
+ Id: details.CodeID,
+ Code: details.Code,
+ },
+ }, nil
+}
diff --git a/internal/api/grpc/user/v2beta/passkey_integration_test.go b/internal/api/grpc/user/v2beta/passkey_integration_test.go
new file mode 100644
index 0000000000..230a744a64
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/passkey_integration_test.go
@@ -0,0 +1,319 @@
+//go:build integration
+
+package user_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/structpb"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/integration"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func TestServer_RegisterPasskey(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{
+ UserId: userID,
+ Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{},
+ })
+ require.NoError(t, err)
+
+ // We also need a user session
+ Tester.RegisterUserPasskey(CTX, userID)
+ _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID)
+
+ type args struct {
+ ctx context.Context
+ req *user.RegisterPasskeyRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.RegisterPasskeyResponse
+ wantErr bool
+ }{
+ {
+ name: "missing user id",
+ args: args{
+ ctx: CTX,
+ req: &user.RegisterPasskeyRequest{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "register code",
+ args: args{
+ ctx: CTX,
+ req: &user.RegisterPasskeyRequest{
+ UserId: userID,
+ Code: reg.GetCode(),
+ Authenticator: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM,
+ },
+ },
+ want: &user.RegisterPasskeyResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "reuse code (not allowed)",
+ args: args{
+ ctx: CTX,
+ req: &user.RegisterPasskeyRequest{
+ UserId: userID,
+ Code: reg.GetCode(),
+ Authenticator: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "wrong code",
+ args: args{
+ ctx: CTX,
+ req: &user.RegisterPasskeyRequest{
+ UserId: userID,
+ Code: &user.PasskeyRegistrationCode{
+ Id: reg.GetCode().GetId(),
+ Code: "foobar",
+ },
+ Authenticator: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_CROSS_PLATFORM,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "user mismatch",
+ args: args{
+ ctx: CTX,
+ req: &user.RegisterPasskeyRequest{
+ UserId: userID,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "user setting its own passkey",
+ args: args{
+ ctx: Tester.WithAuthorizationToken(CTX, sessionToken),
+ req: &user.RegisterPasskeyRequest{
+ UserId: userID,
+ },
+ },
+ want: &user.RegisterPasskeyResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.RegisterPasskey(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ integration.AssertDetails(t, tt.want, got)
+ if tt.want != nil {
+ assert.NotEmpty(t, got.GetPasskeyId())
+ assert.NotEmpty(t, got.GetPublicKeyCredentialCreationOptions())
+ _, err = Tester.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions())
+ require.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestServer_VerifyPasskeyRegistration(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{
+ UserId: userID,
+ Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{},
+ })
+ require.NoError(t, err)
+ pkr, err := Client.RegisterPasskey(CTX, &user.RegisterPasskeyRequest{
+ UserId: userID,
+ Code: reg.GetCode(),
+ })
+ require.NoError(t, err)
+ require.NotEmpty(t, pkr.GetPasskeyId())
+ require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions())
+
+ attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions())
+ require.NoError(t, err)
+
+ type args struct {
+ ctx context.Context
+ req *user.VerifyPasskeyRegistrationRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.VerifyPasskeyRegistrationResponse
+ wantErr bool
+ }{
+ {
+ name: "missing user id",
+ args: args{
+ ctx: CTX,
+ req: &user.VerifyPasskeyRegistrationRequest{
+ PasskeyId: pkr.GetPasskeyId(),
+ PublicKeyCredential: attestationResponse,
+ PasskeyName: "nice name",
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "success",
+ args: args{
+ ctx: CTX,
+ req: &user.VerifyPasskeyRegistrationRequest{
+ UserId: userID,
+ PasskeyId: pkr.GetPasskeyId(),
+ PublicKeyCredential: attestationResponse,
+ PasskeyName: "nice name",
+ },
+ },
+ want: &user.VerifyPasskeyRegistrationResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "wrong credential",
+ args: args{
+ ctx: CTX,
+ req: &user.VerifyPasskeyRegistrationRequest{
+ UserId: userID,
+ PasskeyId: pkr.GetPasskeyId(),
+ PublicKeyCredential: &structpb.Struct{
+ Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}},
+ },
+ PasskeyName: "nice name",
+ },
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.VerifyPasskeyRegistration(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_CreatePasskeyRegistrationLink(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+
+ type args struct {
+ ctx context.Context
+ req *user.CreatePasskeyRegistrationLinkRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.CreatePasskeyRegistrationLinkResponse
+ wantCode bool
+ wantErr bool
+ }{
+ {
+ name: "missing user id",
+ args: args{
+ ctx: CTX,
+ req: &user.CreatePasskeyRegistrationLinkRequest{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "send default mail",
+ args: args{
+ ctx: CTX,
+ req: &user.CreatePasskeyRegistrationLinkRequest{
+ UserId: userID,
+ },
+ },
+ want: &user.CreatePasskeyRegistrationLinkResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "send custom url",
+ args: args{
+ ctx: CTX,
+ req: &user.CreatePasskeyRegistrationLinkRequest{
+ UserId: userID,
+ Medium: &user.CreatePasskeyRegistrationLinkRequest_SendLink{
+ SendLink: &user.SendPasskeyRegistrationLink{
+ UrlTemplate: gu.Ptr("https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}"),
+ },
+ },
+ },
+ },
+ want: &user.CreatePasskeyRegistrationLinkResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "return code",
+ args: args{
+ ctx: CTX,
+ req: &user.CreatePasskeyRegistrationLinkRequest{
+ UserId: userID,
+ Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{},
+ },
+ },
+ want: &user.CreatePasskeyRegistrationLinkResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ wantCode: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.CreatePasskeyRegistrationLink(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ integration.AssertDetails(t, tt.want, got)
+ if tt.wantCode {
+ assert.NotEmpty(t, got.GetCode().GetId())
+ assert.NotEmpty(t, got.GetCode().GetId())
+ }
+ })
+ }
+}
diff --git a/internal/api/grpc/user/v2beta/passkey_test.go b/internal/api/grpc/user/v2beta/passkey_test.go
new file mode 100644
index 0000000000..7d45c41756
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/passkey_test.go
@@ -0,0 +1,235 @@
+package user
+
+import (
+ "io"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/types/known/structpb"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/api/grpc"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/zerrors"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func Test_passkeyAuthenticatorToDomain(t *testing.T) {
+ tests := []struct {
+ pa user.PasskeyAuthenticator
+ want domain.AuthenticatorAttachment
+ }{
+ {
+ pa: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_UNSPECIFIED,
+ want: domain.AuthenticatorAttachmentUnspecified,
+ },
+ {
+ pa: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM,
+ want: domain.AuthenticatorAttachmentPlattform,
+ },
+ {
+ pa: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_CROSS_PLATFORM,
+ want: domain.AuthenticatorAttachmentCrossPlattform,
+ },
+ {
+ pa: 999,
+ want: domain.AuthenticatorAttachmentUnspecified,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.pa.String(), func(t *testing.T) {
+ got := passkeyAuthenticatorToDomain(tt.pa)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
+ type args struct {
+ details *domain.WebAuthNRegistrationDetails
+ err error
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.RegisterPasskeyResponse
+ wantErr error
+ }{
+ {
+ name: "an error",
+ args: args{
+ details: nil,
+ err: io.ErrClosedPipe,
+ },
+ wantErr: io.ErrClosedPipe,
+ },
+ {
+ name: "unmarshall error",
+ args: args{
+ details: &domain.WebAuthNRegistrationDetails{
+ ObjectDetails: &domain.ObjectDetails{
+ Sequence: 22,
+ EventDate: time.Unix(3000, 22),
+ ResourceOwner: "me",
+ },
+ ID: "123",
+ PublicKeyCredentialCreationOptions: []byte(`\\`),
+ },
+ err: nil,
+ },
+ wantErr: zerrors.ThrowInternal(nil, "USERv2-Dohr6", "Errors.Internal"),
+ },
+ {
+ name: "ok",
+ args: args{
+ details: &domain.WebAuthNRegistrationDetails{
+ ObjectDetails: &domain.ObjectDetails{
+ Sequence: 22,
+ EventDate: time.Unix(3000, 22),
+ ResourceOwner: "me",
+ },
+ ID: "123",
+ PublicKeyCredentialCreationOptions: []byte(`{"foo": "bar"}`),
+ },
+ err: nil,
+ },
+ want: &user.RegisterPasskeyResponse{
+ Details: &object.Details{
+ Sequence: 22,
+ ChangeDate: ×tamppb.Timestamp{
+ Seconds: 3000,
+ Nanos: 22,
+ },
+ ResourceOwner: "me",
+ },
+ PasskeyId: "123",
+ PublicKeyCredentialCreationOptions: &structpb.Struct{
+ Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}},
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := passkeyRegistrationDetailsToPb(tt.args.details, tt.args.err)
+ require.ErrorIs(t, err, tt.wantErr)
+ if !proto.Equal(tt.want, got) {
+ t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got)
+ }
+ if tt.want != nil {
+ grpc.AllFieldsSet(t, got.ProtoReflect())
+ }
+ })
+ }
+}
+
+func Test_passkeyDetailsToPb(t *testing.T) {
+ type args struct {
+ details *domain.ObjectDetails
+ err error
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.CreatePasskeyRegistrationLinkResponse
+ }{
+ {
+ name: "an error",
+ args: args{
+ details: nil,
+ err: io.ErrClosedPipe,
+ },
+ },
+ {
+ name: "ok",
+ args: args{
+ details: &domain.ObjectDetails{
+ Sequence: 22,
+ EventDate: time.Unix(3000, 22),
+ ResourceOwner: "me",
+ },
+ err: nil,
+ },
+ want: &user.CreatePasskeyRegistrationLinkResponse{
+ Details: &object.Details{
+ Sequence: 22,
+ ChangeDate: ×tamppb.Timestamp{
+ Seconds: 3000,
+ Nanos: 22,
+ },
+ ResourceOwner: "me",
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := passkeyDetailsToPb(tt.args.details, tt.args.err)
+ require.ErrorIs(t, err, tt.args.err)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func Test_passkeyCodeDetailsToPb(t *testing.T) {
+ type args struct {
+ details *domain.PasskeyCodeDetails
+ err error
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.CreatePasskeyRegistrationLinkResponse
+ }{
+ {
+ name: "an error",
+ args: args{
+ details: nil,
+ err: io.ErrClosedPipe,
+ },
+ },
+ {
+ name: "ok",
+ args: args{
+ details: &domain.PasskeyCodeDetails{
+ ObjectDetails: &domain.ObjectDetails{
+ Sequence: 22,
+ EventDate: time.Unix(3000, 22),
+ ResourceOwner: "me",
+ },
+ CodeID: "123",
+ Code: "456",
+ },
+ err: nil,
+ },
+ want: &user.CreatePasskeyRegistrationLinkResponse{
+ Details: &object.Details{
+ Sequence: 22,
+ ChangeDate: ×tamppb.Timestamp{
+ Seconds: 3000,
+ Nanos: 22,
+ },
+ ResourceOwner: "me",
+ },
+ Code: &user.PasskeyRegistrationCode{
+ Id: "123",
+ Code: "456",
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := passkeyCodeDetailsToPb(tt.args.details, tt.args.err)
+ require.ErrorIs(t, err, tt.args.err)
+ assert.Equal(t, tt.want, got)
+ if tt.want != nil {
+ grpc.AllFieldsSet(t, got.ProtoReflect())
+ }
+ })
+ }
+}
diff --git a/internal/api/grpc/user/v2beta/password.go b/internal/api/grpc/user/v2beta/password.go
new file mode 100644
index 0000000000..0de1262215
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/password.go
@@ -0,0 +1,69 @@
+package user
+
+import (
+ "context"
+
+ object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/zerrors"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetRequest) (_ *user.PasswordResetResponse, err error) {
+ var details *domain.ObjectDetails
+ var code *string
+
+ switch m := req.GetMedium().(type) {
+ case *user.PasswordResetRequest_SendLink:
+ details, code, err = s.command.RequestPasswordResetURLTemplate(ctx, req.GetUserId(), m.SendLink.GetUrlTemplate(), notificationTypeToDomain(m.SendLink.GetNotificationType()))
+ case *user.PasswordResetRequest_ReturnCode:
+ details, code, err = s.command.RequestPasswordResetReturnCode(ctx, req.GetUserId())
+ case nil:
+ details, code, err = s.command.RequestPasswordReset(ctx, req.GetUserId())
+ default:
+ err = zerrors.ThrowUnimplementedf(nil, "USERv2-SDeeg", "verification oneOf %T in method RequestPasswordReset not implemented", m)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &user.PasswordResetResponse{
+ Details: object.DomainToDetailsPb(details),
+ VerificationCode: code,
+ }, nil
+}
+
+func notificationTypeToDomain(notificationType user.NotificationType) domain.NotificationType {
+ switch notificationType {
+ case user.NotificationType_NOTIFICATION_TYPE_Email:
+ return domain.NotificationTypeEmail
+ case user.NotificationType_NOTIFICATION_TYPE_SMS:
+ return domain.NotificationTypeSms
+ case user.NotificationType_NOTIFICATION_TYPE_Unspecified:
+ return domain.NotificationTypeEmail
+ default:
+ return domain.NotificationTypeEmail
+ }
+}
+
+func (s *Server) SetPassword(ctx context.Context, req *user.SetPasswordRequest) (_ *user.SetPasswordResponse, err error) {
+ var details *domain.ObjectDetails
+
+ switch v := req.GetVerification().(type) {
+ case *user.SetPasswordRequest_CurrentPassword:
+ details, err = s.command.ChangePassword(ctx, "", req.GetUserId(), v.CurrentPassword, req.GetNewPassword().GetPassword(), "", req.GetNewPassword().GetChangeRequired())
+ case *user.SetPasswordRequest_VerificationCode:
+ details, err = s.command.SetPasswordWithVerifyCode(ctx, "", req.GetUserId(), v.VerificationCode, req.GetNewPassword().GetPassword(), "", req.GetNewPassword().GetChangeRequired())
+ case nil:
+ details, err = s.command.SetPassword(ctx, "", req.GetUserId(), req.GetNewPassword().GetPassword(), req.GetNewPassword().GetChangeRequired())
+ default:
+ err = zerrors.ThrowUnimplementedf(nil, "USERv2-SFdf2", "verification oneOf %T in method SetPasswordRequest not implemented", v)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &user.SetPasswordResponse{
+ Details: object.DomainToDetailsPb(details),
+ }, nil
+}
diff --git a/internal/api/grpc/user/v2beta/password_integration_test.go b/internal/api/grpc/user/v2beta/password_integration_test.go
new file mode 100644
index 0000000000..03b18a5fa7
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/password_integration_test.go
@@ -0,0 +1,232 @@
+//go:build integration
+
+package user_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/integration"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func TestServer_RequestPasswordReset(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+
+ tests := []struct {
+ name string
+ req *user.PasswordResetRequest
+ want *user.PasswordResetResponse
+ wantErr bool
+ }{
+ {
+ name: "default medium",
+ req: &user.PasswordResetRequest{
+ UserId: userID,
+ },
+ want: &user.PasswordResetResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "custom url template",
+ req: &user.PasswordResetRequest{
+ UserId: userID,
+ Medium: &user.PasswordResetRequest_SendLink{
+ SendLink: &user.SendPasswordResetLink{
+ NotificationType: user.NotificationType_NOTIFICATION_TYPE_Email,
+ UrlTemplate: gu.Ptr("https://example.com/password/change?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"),
+ },
+ },
+ },
+ want: &user.PasswordResetResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "template error",
+ req: &user.PasswordResetRequest{
+ UserId: userID,
+ Medium: &user.PasswordResetRequest_SendLink{
+ SendLink: &user.SendPasswordResetLink{
+ UrlTemplate: gu.Ptr("{{"),
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "return code",
+ req: &user.PasswordResetRequest{
+ UserId: userID,
+ Medium: &user.PasswordResetRequest_ReturnCode{
+ ReturnCode: &user.ReturnPasswordResetCode{},
+ },
+ },
+ want: &user.PasswordResetResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ VerificationCode: gu.Ptr("xxx"),
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.PasswordReset(CTX, tt.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ integration.AssertDetails(t, tt.want, got)
+ if tt.want.GetVerificationCode() != "" {
+ assert.NotEmpty(t, got.GetVerificationCode())
+ }
+ })
+ }
+}
+
+func TestServer_SetPassword(t *testing.T) {
+ type args struct {
+ ctx context.Context
+ req *user.SetPasswordRequest
+ }
+ tests := []struct {
+ name string
+ prepare func(request *user.SetPasswordRequest) error
+ args args
+ want *user.SetPasswordResponse
+ wantErr bool
+ }{
+ {
+ name: "missing user id",
+ prepare: func(request *user.SetPasswordRequest) error {
+ return nil
+ },
+ args: args{
+ ctx: CTX,
+ req: &user.SetPasswordRequest{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "set successful",
+ prepare: func(request *user.SetPasswordRequest) error {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ request.UserId = userID
+ return nil
+ },
+ args: args{
+ ctx: CTX,
+ req: &user.SetPasswordRequest{
+ NewPassword: &user.Password{
+ Password: "Secr3tP4ssw0rd!",
+ },
+ },
+ },
+ want: &user.SetPasswordResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "change successful",
+ prepare: func(request *user.SetPasswordRequest) error {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ request.UserId = userID
+ _, err := Client.SetPassword(CTX, &user.SetPasswordRequest{
+ UserId: userID,
+ NewPassword: &user.Password{
+ Password: "InitialPassw0rd!",
+ },
+ })
+ return err
+ },
+ args: args{
+ ctx: CTX,
+ req: &user.SetPasswordRequest{
+ NewPassword: &user.Password{
+ Password: "Secr3tP4ssw0rd!",
+ },
+ Verification: &user.SetPasswordRequest_CurrentPassword{
+ CurrentPassword: "InitialPassw0rd!",
+ },
+ },
+ },
+ want: &user.SetPasswordResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "set with code successful",
+ prepare: func(request *user.SetPasswordRequest) error {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ request.UserId = userID
+ resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{
+ UserId: userID,
+ Medium: &user.PasswordResetRequest_ReturnCode{
+ ReturnCode: &user.ReturnPasswordResetCode{},
+ },
+ })
+ if err != nil {
+ return err
+ }
+ request.Verification = &user.SetPasswordRequest_VerificationCode{
+ VerificationCode: resp.GetVerificationCode(),
+ }
+ return nil
+ },
+ args: args{
+ ctx: CTX,
+ req: &user.SetPasswordRequest{
+ NewPassword: &user.Password{
+ Password: "Secr3tP4ssw0rd!",
+ },
+ },
+ },
+ want: &user.SetPasswordResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.prepare(tt.args.req)
+ require.NoError(t, err)
+
+ got, err := Client.SetPassword(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
diff --git a/internal/api/grpc/user/v2beta/password_test.go b/internal/api/grpc/user/v2beta/password_test.go
new file mode 100644
index 0000000000..5ce9930b39
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/password_test.go
@@ -0,0 +1,39 @@
+package user
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/zitadel/zitadel/internal/domain"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func Test_notificationTypeToDomain(t *testing.T) {
+ tests := []struct {
+ name string
+ notificationType user.NotificationType
+ want domain.NotificationType
+ }{
+ {
+ "unspecified",
+ user.NotificationType_NOTIFICATION_TYPE_Unspecified,
+ domain.NotificationTypeEmail,
+ },
+ {
+ "email",
+ user.NotificationType_NOTIFICATION_TYPE_Email,
+ domain.NotificationTypeEmail,
+ },
+ {
+ "sms",
+ user.NotificationType_NOTIFICATION_TYPE_SMS,
+ domain.NotificationTypeSms,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equalf(t, tt.want, notificationTypeToDomain(tt.notificationType), "notificationTypeToDomain(%v)", tt.notificationType)
+ })
+ }
+}
diff --git a/internal/api/grpc/user/v2beta/phone.go b/internal/api/grpc/user/v2beta/phone.go
new file mode 100644
index 0000000000..eac7eb4e31
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/phone.go
@@ -0,0 +1,102 @@
+package user
+
+import (
+ "context"
+
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/zerrors"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp *user.SetPhoneResponse, err error) {
+ var phone *domain.Phone
+
+ switch v := req.GetVerification().(type) {
+ case *user.SetPhoneRequest_SendCode:
+ phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg)
+ case *user.SetPhoneRequest_ReturnCode:
+ phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg)
+ case *user.SetPhoneRequest_IsVerified:
+ phone, err = s.command.ChangeUserPhoneVerified(ctx, req.GetUserId(), req.GetPhone())
+ case nil:
+ phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg)
+ default:
+ err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetPhone not implemented", v)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &user.SetPhoneResponse{
+ Details: &object.Details{
+ Sequence: phone.Sequence,
+ ChangeDate: timestamppb.New(phone.ChangeDate),
+ ResourceOwner: phone.ResourceOwner,
+ },
+ VerificationCode: phone.PlainCode,
+ }, nil
+}
+
+func (s *Server) RemovePhone(ctx context.Context, req *user.RemovePhoneRequest) (resp *user.RemovePhoneResponse, err error) {
+ details, err := s.command.RemoveUserPhone(ctx,
+ req.GetUserId(),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ return &user.RemovePhoneResponse{
+ Details: &object.Details{
+ Sequence: details.Sequence,
+ ChangeDate: timestamppb.New(details.EventDate),
+ ResourceOwner: details.ResourceOwner,
+ },
+ }, nil
+}
+
+func (s *Server) ResendPhoneCode(ctx context.Context, req *user.ResendPhoneCodeRequest) (resp *user.ResendPhoneCodeResponse, err error) {
+ var phone *domain.Phone
+ switch v := req.GetVerification().(type) {
+ case *user.ResendPhoneCodeRequest_SendCode:
+ phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg)
+ case *user.ResendPhoneCodeRequest_ReturnCode:
+ phone, err = s.command.ResendUserPhoneCodeReturnCode(ctx, req.GetUserId(), s.userCodeAlg)
+ case nil:
+ phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg)
+ default:
+ err = zerrors.ThrowUnimplementedf(nil, "USERv2-ResendUserPhoneCode", "verification oneOf %T in method SetPhone not implemented", v)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &user.ResendPhoneCodeResponse{
+ Details: &object.Details{
+ Sequence: phone.Sequence,
+ ChangeDate: timestamppb.New(phone.ChangeDate),
+ ResourceOwner: phone.ResourceOwner,
+ },
+ VerificationCode: phone.PlainCode,
+ }, nil
+}
+
+func (s *Server) VerifyPhone(ctx context.Context, req *user.VerifyPhoneRequest) (*user.VerifyPhoneResponse, error) {
+ details, err := s.command.VerifyUserPhone(ctx,
+ req.GetUserId(),
+ req.GetVerificationCode(),
+ s.userCodeAlg,
+ )
+ if err != nil {
+ return nil, err
+ }
+ return &user.VerifyPhoneResponse{
+ Details: &object.Details{
+ Sequence: details.Sequence,
+ ChangeDate: timestamppb.New(details.EventDate),
+ ResourceOwner: details.ResourceOwner,
+ },
+ }, nil
+}
diff --git a/internal/api/grpc/user/v2beta/phone_integration_test.go b/internal/api/grpc/user/v2beta/phone_integration_test.go
new file mode 100644
index 0000000000..692f7af5f7
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/phone_integration_test.go
@@ -0,0 +1,344 @@
+//go:build integration
+
+package user_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/integration"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func TestServer_SetPhone(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+
+ tests := []struct {
+ name string
+ req *user.SetPhoneRequest
+ want *user.SetPhoneResponse
+ wantErr bool
+ }{
+ {
+ name: "default verification",
+ req: &user.SetPhoneRequest{
+ UserId: userID,
+ Phone: "+41791234568",
+ },
+ want: &user.SetPhoneResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "send verification",
+ req: &user.SetPhoneRequest{
+ UserId: userID,
+ Phone: "+41791234569",
+ Verification: &user.SetPhoneRequest_SendCode{
+ SendCode: &user.SendPhoneVerificationCode{},
+ },
+ },
+ want: &user.SetPhoneResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "return code",
+ req: &user.SetPhoneRequest{
+ UserId: userID,
+ Phone: "+41791234566",
+ Verification: &user.SetPhoneRequest_ReturnCode{
+ ReturnCode: &user.ReturnPhoneVerificationCode{},
+ },
+ },
+ want: &user.SetPhoneResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ VerificationCode: gu.Ptr("xxx"),
+ },
+ },
+ {
+ name: "is verified true",
+ req: &user.SetPhoneRequest{
+ UserId: userID,
+ Phone: "+41791234565",
+ Verification: &user.SetPhoneRequest_IsVerified{
+ IsVerified: true,
+ },
+ },
+ want: &user.SetPhoneResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "is verified false",
+ req: &user.SetPhoneRequest{
+ UserId: userID,
+ Phone: "+41791234564",
+ Verification: &user.SetPhoneRequest_IsVerified{
+ IsVerified: false,
+ },
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.SetPhone(CTX, tt.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ integration.AssertDetails(t, tt.want, got)
+ if tt.want.GetVerificationCode() != "" {
+ assert.NotEmpty(t, got.GetVerificationCode())
+ }
+ })
+ }
+}
+
+func TestServer_ResendPhoneCode(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ verifiedUserID := Tester.CreateHumanUserVerified(CTX, Tester.Organisation.ID, fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())).GetUserId()
+
+ tests := []struct {
+ name string
+ req *user.ResendPhoneCodeRequest
+ want *user.ResendPhoneCodeResponse
+ wantErr bool
+ }{
+ {
+ name: "user not existing",
+ req: &user.ResendPhoneCodeRequest{
+ UserId: "xxx",
+ },
+ wantErr: true,
+ },
+ {
+ name: "user not existing",
+ req: &user.ResendPhoneCodeRequest{
+ UserId: verifiedUserID,
+ },
+ wantErr: true,
+ },
+ {
+ name: "resend code",
+ req: &user.ResendPhoneCodeRequest{
+ UserId: userID,
+ Verification: &user.ResendPhoneCodeRequest_SendCode{
+ SendCode: &user.SendPhoneVerificationCode{},
+ },
+ },
+ want: &user.ResendPhoneCodeResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "return code",
+ req: &user.ResendPhoneCodeRequest{
+ UserId: userID,
+ Verification: &user.ResendPhoneCodeRequest_ReturnCode{
+ ReturnCode: &user.ReturnPhoneVerificationCode{},
+ },
+ },
+ want: &user.ResendPhoneCodeResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ VerificationCode: gu.Ptr("xxx"),
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.ResendPhoneCode(CTX, tt.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ integration.AssertDetails(t, tt.want, got)
+ if tt.want.GetVerificationCode() != "" {
+ assert.NotEmpty(t, got.GetVerificationCode())
+ }
+ })
+ }
+}
+
+func TestServer_VerifyPhone(t *testing.T) {
+ userResp := Tester.CreateHumanUser(CTX)
+ tests := []struct {
+ name string
+ req *user.VerifyPhoneRequest
+ want *user.VerifyPhoneResponse
+ wantErr bool
+ }{
+ {
+ name: "wrong code",
+ req: &user.VerifyPhoneRequest{
+ UserId: userResp.GetUserId(),
+ VerificationCode: "xxx",
+ },
+ wantErr: true,
+ },
+ {
+ name: "wrong user",
+ req: &user.VerifyPhoneRequest{
+ UserId: "xxx",
+ VerificationCode: userResp.GetPhoneCode(),
+ },
+ wantErr: true,
+ },
+ {
+ name: "verify user",
+ req: &user.VerifyPhoneRequest{
+ UserId: userResp.GetUserId(),
+ VerificationCode: userResp.GetPhoneCode(),
+ },
+ want: &user.VerifyPhoneResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.VerifyPhone(CTX, tt.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_RemovePhone(t *testing.T) {
+ userResp := Tester.CreateHumanUser(CTX)
+ failResp := Tester.CreateHumanUserNoPhone(CTX)
+ otherUser := Tester.CreateHumanUser(CTX).GetUserId()
+ doubleRemoveUser := Tester.CreateHumanUser(CTX)
+
+ Tester.RegisterUserPasskey(CTX, otherUser)
+ _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser)
+
+ tests := []struct {
+ name string
+ ctx context.Context
+ req *user.RemovePhoneRequest
+ want *user.RemovePhoneResponse
+ wantErr bool
+ dep func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error)
+ }{
+ {
+ name: "remove phone",
+ ctx: CTX,
+ req: &user.RemovePhoneRequest{
+ UserId: userResp.GetUserId(),
+ },
+ want: &user.RemovePhoneResponse{
+ Details: &object.Details{
+ Sequence: 1,
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) {
+ return nil, nil
+ },
+ },
+ {
+ name: "user without phone",
+ ctx: CTX,
+ req: &user.RemovePhoneRequest{
+ UserId: failResp.GetUserId(),
+ },
+ wantErr: true,
+ dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) {
+ return nil, nil
+ },
+ },
+ {
+ name: "remove previously deleted phone",
+ ctx: CTX,
+ req: &user.RemovePhoneRequest{
+ UserId: doubleRemoveUser.GetUserId(),
+ },
+ wantErr: true,
+ dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) {
+ return Client.RemovePhone(ctx, &user.RemovePhoneRequest{
+ UserId: doubleRemoveUser.GetUserId(),
+ })
+ },
+ },
+ {
+ name: "no user id",
+ ctx: CTX,
+ req: &user.RemovePhoneRequest{},
+ wantErr: true,
+ dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) {
+ return nil, nil
+ },
+ },
+ {
+ name: "other user, no permission",
+ ctx: Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser),
+ req: &user.RemovePhoneRequest{
+ UserId: userResp.GetUserId(),
+ },
+ wantErr: true,
+ dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) {
+ return nil, nil
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, depErr := tt.dep(tt.ctx, tt.req.UserId)
+ require.NoError(t, depErr)
+
+ got, err := Client.RemovePhone(tt.ctx, tt.req)
+
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
diff --git a/internal/api/grpc/user/v2beta/query.go b/internal/api/grpc/user/v2beta/query.go
new file mode 100644
index 0000000000..0eaeba5ca1
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/query.go
@@ -0,0 +1,338 @@
+package user
+
+import (
+ "context"
+
+ "github.com/muhlemmer/gu"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
+ object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/query"
+ "github.com/zitadel/zitadel/internal/zerrors"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) (_ *user.GetUserByIDResponse, err error) {
+ resp, err := s.query.GetUserByID(ctx, true, req.GetUserId())
+ if err != nil {
+ return nil, err
+ }
+ if authz.GetCtxData(ctx).UserID != req.GetUserId() {
+ if err := s.checkPermission(ctx, domain.PermissionUserRead, resp.ResourceOwner, req.GetUserId()); err != nil {
+ return nil, err
+ }
+ }
+ return &user.GetUserByIDResponse{
+ Details: object.DomainToDetailsPb(&domain.ObjectDetails{
+ Sequence: resp.Sequence,
+ EventDate: resp.ChangeDate,
+ ResourceOwner: resp.ResourceOwner,
+ }),
+ User: userToPb(resp, s.assetAPIPrefix(ctx)),
+ }, nil
+}
+
+func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*user.ListUsersResponse, error) {
+ queries, err := listUsersRequestToModel(req)
+ if err != nil {
+ return nil, err
+ }
+ res, err := s.query.SearchUsers(ctx, queries)
+ if err != nil {
+ return nil, err
+ }
+ res.RemoveNoPermission(ctx, s.checkPermission)
+ return &user.ListUsersResponse{
+ Result: UsersToPb(res.Users, s.assetAPIPrefix(ctx)),
+ Details: object.ToListDetails(res.SearchResponse),
+ }, nil
+}
+
+func UsersToPb(users []*query.User, assetPrefix string) []*user.User {
+ u := make([]*user.User, len(users))
+ for i, user := range users {
+ u[i] = userToPb(user, assetPrefix)
+ }
+ return u
+}
+
+func userToPb(userQ *query.User, assetPrefix string) *user.User {
+ return &user.User{
+ UserId: userQ.ID,
+ Details: object.DomainToDetailsPb(&domain.ObjectDetails{
+ Sequence: userQ.Sequence,
+ EventDate: userQ.ChangeDate,
+ ResourceOwner: userQ.ResourceOwner,
+ }),
+ State: userStateToPb(userQ.State),
+ Username: userQ.Username,
+ LoginNames: userQ.LoginNames,
+ PreferredLoginName: userQ.PreferredLoginName,
+ Type: userTypeToPb(userQ, assetPrefix),
+ }
+}
+
+func userTypeToPb(userQ *query.User, assetPrefix string) user.UserType {
+ if userQ.Human != nil {
+ return &user.User_Human{
+ Human: humanToPb(userQ.Human, assetPrefix, userQ.ResourceOwner),
+ }
+ }
+ if userQ.Machine != nil {
+ return &user.User_Machine{
+ Machine: machineToPb(userQ.Machine),
+ }
+ }
+ return nil
+}
+
+func humanToPb(userQ *query.Human, assetPrefix, owner string) *user.HumanUser {
+ var passwordChanged *timestamppb.Timestamp
+ if !userQ.PasswordChanged.IsZero() {
+ passwordChanged = timestamppb.New(userQ.PasswordChanged)
+ }
+ return &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: userQ.FirstName,
+ FamilyName: userQ.LastName,
+ NickName: gu.Ptr(userQ.NickName),
+ DisplayName: gu.Ptr(userQ.DisplayName),
+ PreferredLanguage: gu.Ptr(userQ.PreferredLanguage.String()),
+ Gender: gu.Ptr(genderToPb(userQ.Gender)),
+ AvatarUrl: domain.AvatarURL(assetPrefix, owner, userQ.AvatarKey),
+ },
+ Email: &user.HumanEmail{
+ Email: string(userQ.Email),
+ IsVerified: userQ.IsEmailVerified,
+ },
+ Phone: &user.HumanPhone{
+ Phone: string(userQ.Phone),
+ IsVerified: userQ.IsPhoneVerified,
+ },
+ PasswordChangeRequired: userQ.PasswordChangeRequired,
+ PasswordChanged: passwordChanged,
+ }
+}
+
+func machineToPb(userQ *query.Machine) *user.MachineUser {
+ return &user.MachineUser{
+ Name: userQ.Name,
+ Description: userQ.Description,
+ HasSecret: userQ.EncodedSecret != "",
+ AccessTokenType: accessTokenTypeToPb(userQ.AccessTokenType),
+ }
+}
+
+func userStateToPb(state domain.UserState) user.UserState {
+ switch state {
+ case domain.UserStateActive:
+ return user.UserState_USER_STATE_ACTIVE
+ case domain.UserStateInactive:
+ return user.UserState_USER_STATE_INACTIVE
+ case domain.UserStateDeleted:
+ return user.UserState_USER_STATE_DELETED
+ case domain.UserStateInitial:
+ return user.UserState_USER_STATE_INITIAL
+ case domain.UserStateLocked:
+ return user.UserState_USER_STATE_LOCKED
+ case domain.UserStateUnspecified:
+ return user.UserState_USER_STATE_UNSPECIFIED
+ case domain.UserStateSuspend:
+ return user.UserState_USER_STATE_UNSPECIFIED
+ default:
+ return user.UserState_USER_STATE_UNSPECIFIED
+ }
+}
+
+func genderToPb(gender domain.Gender) user.Gender {
+ switch gender {
+ case domain.GenderDiverse:
+ return user.Gender_GENDER_DIVERSE
+ case domain.GenderFemale:
+ return user.Gender_GENDER_FEMALE
+ case domain.GenderMale:
+ return user.Gender_GENDER_MALE
+ case domain.GenderUnspecified:
+ return user.Gender_GENDER_UNSPECIFIED
+ default:
+ return user.Gender_GENDER_UNSPECIFIED
+ }
+}
+
+func accessTokenTypeToPb(accessTokenType domain.OIDCTokenType) user.AccessTokenType {
+ switch accessTokenType {
+ case domain.OIDCTokenTypeBearer:
+ return user.AccessTokenType_ACCESS_TOKEN_TYPE_BEARER
+ case domain.OIDCTokenTypeJWT:
+ return user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT
+ default:
+ return user.AccessTokenType_ACCESS_TOKEN_TYPE_BEARER
+ }
+}
+
+func listUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueries, error) {
+ offset, limit, asc := object.ListQueryToQuery(req.Query)
+ queries, err := userQueriesToQuery(req.Queries, 0 /*start from level 0*/)
+ if err != nil {
+ return nil, err
+ }
+ return &query.UserSearchQueries{
+ SearchRequest: query.SearchRequest{
+ Offset: offset,
+ Limit: limit,
+ Asc: asc,
+ SortingColumn: userFieldNameToSortingColumn(req.SortingColumn),
+ },
+ Queries: queries,
+ }, nil
+}
+
+func userFieldNameToSortingColumn(field user.UserFieldName) query.Column {
+ switch field {
+ case user.UserFieldName_USER_FIELD_NAME_EMAIL:
+ return query.HumanEmailCol
+ case user.UserFieldName_USER_FIELD_NAME_FIRST_NAME:
+ return query.HumanFirstNameCol
+ case user.UserFieldName_USER_FIELD_NAME_LAST_NAME:
+ return query.HumanLastNameCol
+ case user.UserFieldName_USER_FIELD_NAME_DISPLAY_NAME:
+ return query.HumanDisplayNameCol
+ case user.UserFieldName_USER_FIELD_NAME_USER_NAME:
+ return query.UserUsernameCol
+ case user.UserFieldName_USER_FIELD_NAME_STATE:
+ return query.UserStateCol
+ case user.UserFieldName_USER_FIELD_NAME_TYPE:
+ return query.UserTypeCol
+ case user.UserFieldName_USER_FIELD_NAME_NICK_NAME:
+ return query.HumanNickNameCol
+ case user.UserFieldName_USER_FIELD_NAME_CREATION_DATE:
+ return query.UserCreationDateCol
+ case user.UserFieldName_USER_FIELD_NAME_UNSPECIFIED:
+ return query.UserIDCol
+ default:
+ return query.UserIDCol
+ }
+}
+
+func userQueriesToQuery(queries []*user.SearchQuery, level uint8) (_ []query.SearchQuery, err error) {
+ q := make([]query.SearchQuery, len(queries))
+ for i, query := range queries {
+ q[i], err = userQueryToQuery(query, level)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return q, nil
+}
+
+func userQueryToQuery(query *user.SearchQuery, level uint8) (query.SearchQuery, error) {
+ if level > 20 {
+ // can't go deeper than 20 levels of nesting.
+ return nil, zerrors.ThrowInvalidArgument(nil, "USER-zsQ97", "Errors.Query.TooManyNestingLevels")
+ }
+ switch q := query.Query.(type) {
+ case *user.SearchQuery_UserNameQuery:
+ return userNameQueryToQuery(q.UserNameQuery)
+ case *user.SearchQuery_FirstNameQuery:
+ return firstNameQueryToQuery(q.FirstNameQuery)
+ case *user.SearchQuery_LastNameQuery:
+ return lastNameQueryToQuery(q.LastNameQuery)
+ case *user.SearchQuery_NickNameQuery:
+ return nickNameQueryToQuery(q.NickNameQuery)
+ case *user.SearchQuery_DisplayNameQuery:
+ return displayNameQueryToQuery(q.DisplayNameQuery)
+ case *user.SearchQuery_EmailQuery:
+ return emailQueryToQuery(q.EmailQuery)
+ case *user.SearchQuery_StateQuery:
+ return stateQueryToQuery(q.StateQuery)
+ case *user.SearchQuery_TypeQuery:
+ return typeQueryToQuery(q.TypeQuery)
+ case *user.SearchQuery_LoginNameQuery:
+ return loginNameQueryToQuery(q.LoginNameQuery)
+ case *user.SearchQuery_OrganizationIdQuery:
+ return resourceOwnerQueryToQuery(q.OrganizationIdQuery)
+ case *user.SearchQuery_InUserIdsQuery:
+ return inUserIdsQueryToQuery(q.InUserIdsQuery)
+ case *user.SearchQuery_OrQuery:
+ return orQueryToQuery(q.OrQuery, level)
+ case *user.SearchQuery_AndQuery:
+ return andQueryToQuery(q.AndQuery, level)
+ case *user.SearchQuery_NotQuery:
+ return notQueryToQuery(q.NotQuery, level)
+ case *user.SearchQuery_InUserEmailsQuery:
+ return inUserEmailsQueryToQuery(q.InUserEmailsQuery)
+ default:
+ return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-vR9nC", "List.Query.Invalid")
+ }
+}
+
+func userNameQueryToQuery(q *user.UserNameQuery) (query.SearchQuery, error) {
+ return query.NewUserUsernameSearchQuery(q.UserName, object.TextMethodToQuery(q.Method))
+}
+
+func firstNameQueryToQuery(q *user.FirstNameQuery) (query.SearchQuery, error) {
+ return query.NewUserFirstNameSearchQuery(q.FirstName, object.TextMethodToQuery(q.Method))
+}
+
+func lastNameQueryToQuery(q *user.LastNameQuery) (query.SearchQuery, error) {
+ return query.NewUserLastNameSearchQuery(q.LastName, object.TextMethodToQuery(q.Method))
+}
+
+func nickNameQueryToQuery(q *user.NickNameQuery) (query.SearchQuery, error) {
+ return query.NewUserNickNameSearchQuery(q.NickName, object.TextMethodToQuery(q.Method))
+}
+
+func displayNameQueryToQuery(q *user.DisplayNameQuery) (query.SearchQuery, error) {
+ return query.NewUserDisplayNameSearchQuery(q.DisplayName, object.TextMethodToQuery(q.Method))
+}
+
+func emailQueryToQuery(q *user.EmailQuery) (query.SearchQuery, error) {
+ return query.NewUserEmailSearchQuery(q.EmailAddress, object.TextMethodToQuery(q.Method))
+}
+
+func stateQueryToQuery(q *user.StateQuery) (query.SearchQuery, error) {
+ return query.NewUserStateSearchQuery(int32(q.State))
+}
+
+func typeQueryToQuery(q *user.TypeQuery) (query.SearchQuery, error) {
+ return query.NewUserTypeSearchQuery(int32(q.Type))
+}
+
+func loginNameQueryToQuery(q *user.LoginNameQuery) (query.SearchQuery, error) {
+ return query.NewUserLoginNameExistsQuery(q.LoginName, object.TextMethodToQuery(q.Method))
+}
+
+func resourceOwnerQueryToQuery(q *user.OrganizationIdQuery) (query.SearchQuery, error) {
+ return query.NewUserResourceOwnerSearchQuery(q.OrganizationId, query.TextEquals)
+}
+
+func inUserIdsQueryToQuery(q *user.InUserIDQuery) (query.SearchQuery, error) {
+ return query.NewUserInUserIdsSearchQuery(q.UserIds)
+}
+func orQueryToQuery(q *user.OrQuery, level uint8) (query.SearchQuery, error) {
+ mappedQueries, err := userQueriesToQuery(q.Queries, level+1)
+ if err != nil {
+ return nil, err
+ }
+ return query.NewUserOrSearchQuery(mappedQueries)
+}
+func andQueryToQuery(q *user.AndQuery, level uint8) (query.SearchQuery, error) {
+ mappedQueries, err := userQueriesToQuery(q.Queries, level+1)
+ if err != nil {
+ return nil, err
+ }
+ return query.NewUserAndSearchQuery(mappedQueries)
+}
+func notQueryToQuery(q *user.NotQuery, level uint8) (query.SearchQuery, error) {
+ mappedQuery, err := userQueryToQuery(q.Query, level+1)
+ if err != nil {
+ return nil, err
+ }
+ return query.NewUserNotSearchQuery(mappedQuery)
+}
+
+func inUserEmailsQueryToQuery(q *user.InUserEmailsQuery) (query.SearchQuery, error) {
+ return query.NewUserInUserEmailsSearchQuery(q.UserEmails)
+}
diff --git a/internal/api/grpc/user/v2beta/query_integration_test.go b/internal/api/grpc/user/v2beta/query_integration_test.go
new file mode 100644
index 0000000000..124e47bb27
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/query_integration_test.go
@@ -0,0 +1,982 @@
+//go:build integration
+
+package user_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/integration"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ object_v2beta "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func detailsV2ToV2beta(obj *object.Details) *object_v2beta.Details {
+ return &object_v2beta.Details{
+ Sequence: obj.GetSequence(),
+ ChangeDate: obj.GetChangeDate(),
+ ResourceOwner: obj.GetResourceOwner(),
+ }
+}
+
+func TestServer_GetUserByID(t *testing.T) {
+ orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()))
+ type args struct {
+ ctx context.Context
+ req *user.GetUserByIDRequest
+ dep func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error)
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.GetUserByIDResponse
+ wantErr bool
+ }{
+ {
+ name: "user by ID, no id provided",
+ args: args{
+ IamCTX,
+ &user.GetUserByIDRequest{
+ UserId: "",
+ },
+ func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) {
+ return nil, nil
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "user by ID, not found",
+ args: args{
+ IamCTX,
+ &user.GetUserByIDRequest{
+ UserId: "unknown",
+ },
+ func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) {
+ return nil, nil
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "user by ID, ok",
+ args: args{
+ IamCTX,
+ &user.GetUserByIDRequest{},
+ func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) {
+ resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username)
+ request.UserId = resp.GetUserId()
+ return &userAttr{resp.GetUserId(), username, nil, resp.GetDetails()}, nil
+ },
+ },
+ want: &user.GetUserByIDResponse{
+ User: &user.User{
+ State: user.UserState_USER_STATE_ACTIVE,
+ Username: "",
+ LoginNames: nil,
+ PreferredLoginName: "",
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "Mickey",
+ FamilyName: "Mouse",
+ NickName: gu.Ptr("Mickey"),
+ DisplayName: gu.Ptr("Mickey Mouse"),
+ PreferredLanguage: gu.Ptr("nl"),
+ Gender: user.Gender_GENDER_MALE.Enum(),
+ AvatarUrl: "",
+ },
+ Email: &user.HumanEmail{
+ IsVerified: true,
+ },
+ Phone: &user.HumanPhone{
+ Phone: "+41791234567",
+ IsVerified: true,
+ },
+ },
+ },
+ },
+ Details: &object_v2beta.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: orgResp.OrganizationId,
+ },
+ },
+ },
+ {
+ name: "user by ID, passwordChangeRequired, ok",
+ args: args{
+ IamCTX,
+ &user.GetUserByIDRequest{},
+ func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) {
+ resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username)
+ request.UserId = resp.GetUserId()
+ details := Tester.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true)
+ return &userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()}, nil
+ },
+ },
+ want: &user.GetUserByIDResponse{
+ User: &user.User{
+ State: user.UserState_USER_STATE_ACTIVE,
+ Username: "",
+ LoginNames: nil,
+ PreferredLoginName: "",
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "Mickey",
+ FamilyName: "Mouse",
+ NickName: gu.Ptr("Mickey"),
+ DisplayName: gu.Ptr("Mickey Mouse"),
+ PreferredLanguage: gu.Ptr("nl"),
+ Gender: user.Gender_GENDER_MALE.Enum(),
+ AvatarUrl: "",
+ },
+ Email: &user.HumanEmail{
+ IsVerified: true,
+ },
+ Phone: &user.HumanPhone{
+ Phone: "+41791234567",
+ IsVerified: true,
+ },
+ PasswordChangeRequired: true,
+ PasswordChanged: timestamppb.Now(),
+ },
+ },
+ },
+ Details: &object_v2beta.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: orgResp.OrganizationId,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ username := fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())
+ userAttr, err := tt.args.dep(tt.args.ctx, username, tt.args.req)
+ require.NoError(t, err)
+ retryDuration := time.Minute
+ if ctxDeadline, ok := CTX.Deadline(); ok {
+ retryDuration = time.Until(ctxDeadline)
+ }
+ require.EventuallyWithT(t, func(ttt *assert.CollectT) {
+ got, getErr := Client.GetUserByID(tt.args.ctx, tt.args.req)
+ assertErr := assert.NoError
+ if tt.wantErr {
+ assertErr = assert.Error
+ }
+ assertErr(ttt, getErr)
+ if getErr != nil {
+ return
+ }
+ tt.want.User.Details = detailsV2ToV2beta(userAttr.Details)
+ tt.want.User.UserId = userAttr.UserID
+ tt.want.User.Username = userAttr.Username
+ tt.want.User.PreferredLoginName = userAttr.Username
+ tt.want.User.LoginNames = []string{userAttr.Username}
+ if human := tt.want.User.GetHuman(); human != nil {
+ human.Email.Email = userAttr.Username
+ if tt.want.User.GetHuman().GetPasswordChanged() != nil {
+ human.PasswordChanged = userAttr.Changed
+ }
+ }
+ assert.Equal(ttt, tt.want.User, got.User)
+ integration.AssertDetails(t, tt.want, got)
+ }, retryDuration, time.Second)
+ })
+ }
+}
+
+func TestServer_GetUserByID_Permission(t *testing.T) {
+ timeNow := time.Now().UTC()
+ newOrgOwnerEmail := fmt.Sprintf("%d@permission.get.com", timeNow.UnixNano())
+ newOrg := Tester.CreateOrganization(IamCTX, fmt.Sprintf("GetHuman%d", time.Now().UnixNano()), newOrgOwnerEmail)
+ newUserID := newOrg.CreatedAdmins[0].GetUserId()
+ type args struct {
+ ctx context.Context
+ req *user.GetUserByIDRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.GetUserByIDResponse
+ wantErr bool
+ }{
+ {
+ name: "System, ok",
+ args: args{
+ SystemCTX,
+ &user.GetUserByIDRequest{
+ UserId: newUserID,
+ },
+ },
+ want: &user.GetUserByIDResponse{
+ User: &user.User{
+ State: user.UserState_USER_STATE_ACTIVE,
+ Username: "",
+ LoginNames: nil,
+ PreferredLoginName: "",
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "firstname",
+ FamilyName: "lastname",
+ NickName: gu.Ptr(""),
+ DisplayName: gu.Ptr("firstname lastname"),
+ PreferredLanguage: gu.Ptr("und"),
+ Gender: user.Gender_GENDER_UNSPECIFIED.Enum(),
+ AvatarUrl: "",
+ },
+ Email: &user.HumanEmail{
+ Email: newOrgOwnerEmail,
+ },
+ Phone: &user.HumanPhone{},
+ },
+ },
+ },
+ Details: &object_v2beta.Details{
+ ChangeDate: timestamppb.New(timeNow),
+ ResourceOwner: newOrg.GetOrganizationId(),
+ },
+ },
+ },
+ {
+ name: "Instance, ok",
+ args: args{
+ IamCTX,
+ &user.GetUserByIDRequest{
+ UserId: newUserID,
+ },
+ },
+ want: &user.GetUserByIDResponse{
+ User: &user.User{
+ State: user.UserState_USER_STATE_ACTIVE,
+ Username: "",
+ LoginNames: nil,
+ PreferredLoginName: "",
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "firstname",
+ FamilyName: "lastname",
+ NickName: gu.Ptr(""),
+ DisplayName: gu.Ptr("firstname lastname"),
+ PreferredLanguage: gu.Ptr("und"),
+ Gender: user.Gender_GENDER_UNSPECIFIED.Enum(),
+ AvatarUrl: "",
+ },
+ Email: &user.HumanEmail{
+ Email: newOrgOwnerEmail,
+ },
+ Phone: &user.HumanPhone{},
+ },
+ },
+ },
+ Details: &object_v2beta.Details{
+ ChangeDate: timestamppb.New(timeNow),
+ ResourceOwner: newOrg.GetOrganizationId(),
+ },
+ },
+ },
+ {
+ name: "Org, error",
+ args: args{
+ CTX,
+ &user.GetUserByIDRequest{
+ UserId: newUserID,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "User, error",
+ args: args{
+ UserCTX,
+ &user.GetUserByIDRequest{
+ UserId: newUserID,
+ },
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.GetUserByID(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ tt.want.User.UserId = tt.args.req.GetUserId()
+ tt.want.User.Username = newOrgOwnerEmail
+ tt.want.User.PreferredLoginName = newOrgOwnerEmail
+ tt.want.User.LoginNames = []string{newOrgOwnerEmail}
+ if human := tt.want.User.GetHuman(); human != nil {
+ human.Email.Email = newOrgOwnerEmail
+ }
+ // details tested in GetUserByID
+ tt.want.User.Details = got.User.GetDetails()
+
+ assert.Equal(t, tt.want.User, got.User)
+ }
+ })
+ }
+}
+
+type userAttr struct {
+ UserID string
+ Username string
+ Changed *timestamppb.Timestamp
+ Details *object.Details
+}
+
+func TestServer_ListUsers(t *testing.T) {
+ orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()))
+ userResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listusers.com", time.Now().UnixNano()))
+ type args struct {
+ ctx context.Context
+ count int
+ req *user.ListUsersRequest
+ dep func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error)
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.ListUsersResponse
+ wantErr bool
+ }{
+ {
+ name: "list user by id, no permission",
+ args: args{
+ UserCTX,
+ 0,
+ &user.ListUsersRequest{},
+ func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) {
+ request.Queries = append(request.Queries, InUserIDsQuery([]string{userResp.UserId}))
+ return []userAttr{}, nil
+ },
+ },
+ want: &user.ListUsersResponse{
+ Details: &object_v2beta.ListDetails{
+ TotalResult: 0,
+ Timestamp: timestamppb.Now(),
+ },
+ SortingColumn: 0,
+ Result: []*user.User{},
+ },
+ },
+ {
+ name: "list user by id, ok",
+ args: args{
+ IamCTX,
+ 1,
+ &user.ListUsersRequest{
+ Queries: []*user.SearchQuery{
+ OrganizationIdQuery(orgResp.OrganizationId),
+ },
+ },
+ func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) {
+ infos := make([]userAttr, len(usernames))
+ userIDs := make([]string, len(usernames))
+ for i, username := range usernames {
+ resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username)
+ userIDs[i] = resp.GetUserId()
+ infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()}
+ }
+ request.Queries = append(request.Queries, InUserIDsQuery(userIDs))
+ return infos, nil
+ },
+ },
+ want: &user.ListUsersResponse{
+ Details: &object_v2beta.ListDetails{
+ TotalResult: 1,
+ Timestamp: timestamppb.Now(),
+ },
+ SortingColumn: 0,
+ Result: []*user.User{
+ {
+ State: user.UserState_USER_STATE_ACTIVE,
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "Mickey",
+ FamilyName: "Mouse",
+ NickName: gu.Ptr("Mickey"),
+ DisplayName: gu.Ptr("Mickey Mouse"),
+ PreferredLanguage: gu.Ptr("nl"),
+ Gender: user.Gender_GENDER_MALE.Enum(),
+ },
+ Email: &user.HumanEmail{
+ IsVerified: true,
+ },
+ Phone: &user.HumanPhone{
+ Phone: "+41791234567",
+ IsVerified: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "list user by id, passwordChangeRequired, ok",
+ args: args{
+ IamCTX,
+ 1,
+ &user.ListUsersRequest{
+ Queries: []*user.SearchQuery{
+ OrganizationIdQuery(orgResp.OrganizationId),
+ },
+ },
+ func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) {
+ infos := make([]userAttr, len(usernames))
+ userIDs := make([]string, len(usernames))
+ for i, username := range usernames {
+ resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username)
+ userIDs[i] = resp.GetUserId()
+ details := Tester.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true)
+ infos[i] = userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()}
+ }
+ request.Queries = append(request.Queries, InUserIDsQuery(userIDs))
+ return infos, nil
+ },
+ },
+ want: &user.ListUsersResponse{
+ Details: &object_v2beta.ListDetails{
+ TotalResult: 1,
+ Timestamp: timestamppb.Now(),
+ },
+ SortingColumn: 0,
+ Result: []*user.User{
+ {
+ State: user.UserState_USER_STATE_ACTIVE,
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "Mickey",
+ FamilyName: "Mouse",
+ NickName: gu.Ptr("Mickey"),
+ DisplayName: gu.Ptr("Mickey Mouse"),
+ PreferredLanguage: gu.Ptr("nl"),
+ Gender: user.Gender_GENDER_MALE.Enum(),
+ },
+ Email: &user.HumanEmail{
+ IsVerified: true,
+ },
+ Phone: &user.HumanPhone{
+ Phone: "+41791234567",
+ IsVerified: true,
+ },
+ PasswordChangeRequired: true,
+ PasswordChanged: timestamppb.Now(),
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "list user by id multiple, ok",
+ args: args{
+ IamCTX,
+ 3,
+ &user.ListUsersRequest{
+ Queries: []*user.SearchQuery{
+ OrganizationIdQuery(orgResp.OrganizationId),
+ },
+ },
+ func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) {
+ infos := make([]userAttr, len(usernames))
+ userIDs := make([]string, len(usernames))
+ for i, username := range usernames {
+ resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username)
+ userIDs[i] = resp.GetUserId()
+ infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()}
+ }
+ request.Queries = append(request.Queries, InUserIDsQuery(userIDs))
+ return infos, nil
+ },
+ },
+ want: &user.ListUsersResponse{
+ Details: &object_v2beta.ListDetails{
+ TotalResult: 3,
+ Timestamp: timestamppb.Now(),
+ },
+ SortingColumn: 0,
+ Result: []*user.User{
+ {
+ State: user.UserState_USER_STATE_ACTIVE,
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "Mickey",
+ FamilyName: "Mouse",
+ NickName: gu.Ptr("Mickey"),
+ DisplayName: gu.Ptr("Mickey Mouse"),
+ PreferredLanguage: gu.Ptr("nl"),
+ Gender: user.Gender_GENDER_MALE.Enum(),
+ },
+ Email: &user.HumanEmail{
+ IsVerified: true,
+ },
+ Phone: &user.HumanPhone{
+ Phone: "+41791234567",
+ IsVerified: true,
+ },
+ },
+ },
+ }, {
+ State: user.UserState_USER_STATE_ACTIVE,
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "Mickey",
+ FamilyName: "Mouse",
+ NickName: gu.Ptr("Mickey"),
+ DisplayName: gu.Ptr("Mickey Mouse"),
+ PreferredLanguage: gu.Ptr("nl"),
+ Gender: user.Gender_GENDER_MALE.Enum(),
+ },
+ Email: &user.HumanEmail{
+ IsVerified: true,
+ },
+ Phone: &user.HumanPhone{
+ Phone: "+41791234567",
+ IsVerified: true,
+ },
+ },
+ },
+ }, {
+ State: user.UserState_USER_STATE_ACTIVE,
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "Mickey",
+ FamilyName: "Mouse",
+ NickName: gu.Ptr("Mickey"),
+ DisplayName: gu.Ptr("Mickey Mouse"),
+ PreferredLanguage: gu.Ptr("nl"),
+ Gender: user.Gender_GENDER_MALE.Enum(),
+ },
+ Email: &user.HumanEmail{
+ IsVerified: true,
+ },
+ Phone: &user.HumanPhone{
+ Phone: "+41791234567",
+ IsVerified: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "list user by username, ok",
+ args: args{
+ IamCTX,
+ 1,
+ &user.ListUsersRequest{
+ Queries: []*user.SearchQuery{
+ OrganizationIdQuery(orgResp.OrganizationId),
+ },
+ },
+ func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) {
+ infos := make([]userAttr, len(usernames))
+ userIDs := make([]string, len(usernames))
+ for i, username := range usernames {
+ resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username)
+ userIDs[i] = resp.GetUserId()
+ infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()}
+ request.Queries = append(request.Queries, UsernameQuery(username))
+ }
+ return infos, nil
+ },
+ },
+ want: &user.ListUsersResponse{
+ Details: &object_v2beta.ListDetails{
+ TotalResult: 1,
+ Timestamp: timestamppb.Now(),
+ },
+ SortingColumn: 0,
+ Result: []*user.User{
+ {
+ State: user.UserState_USER_STATE_ACTIVE,
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "Mickey",
+ FamilyName: "Mouse",
+ NickName: gu.Ptr("Mickey"),
+ DisplayName: gu.Ptr("Mickey Mouse"),
+ PreferredLanguage: gu.Ptr("nl"),
+ Gender: user.Gender_GENDER_MALE.Enum(),
+ },
+ Email: &user.HumanEmail{
+ IsVerified: true,
+ },
+ Phone: &user.HumanPhone{
+ Phone: "+41791234567",
+ IsVerified: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "list user in emails, ok",
+ args: args{
+ IamCTX,
+ 1,
+ &user.ListUsersRequest{
+ Queries: []*user.SearchQuery{
+ OrganizationIdQuery(orgResp.OrganizationId),
+ },
+ },
+ func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) {
+ infos := make([]userAttr, len(usernames))
+ for i, username := range usernames {
+ resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username)
+ infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()}
+ }
+ request.Queries = append(request.Queries, InUserEmailsQuery(usernames))
+ return infos, nil
+ },
+ },
+ want: &user.ListUsersResponse{
+ Details: &object_v2beta.ListDetails{
+ TotalResult: 1,
+ Timestamp: timestamppb.Now(),
+ },
+ SortingColumn: 0,
+ Result: []*user.User{
+ {
+ State: user.UserState_USER_STATE_ACTIVE,
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "Mickey",
+ FamilyName: "Mouse",
+ NickName: gu.Ptr("Mickey"),
+ DisplayName: gu.Ptr("Mickey Mouse"),
+ PreferredLanguage: gu.Ptr("nl"),
+ Gender: user.Gender_GENDER_MALE.Enum(),
+ },
+ Email: &user.HumanEmail{
+ IsVerified: true,
+ },
+ Phone: &user.HumanPhone{
+ Phone: "+41791234567",
+ IsVerified: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "list user in emails multiple, ok",
+ args: args{
+ IamCTX,
+ 3,
+ &user.ListUsersRequest{
+ Queries: []*user.SearchQuery{
+ OrganizationIdQuery(orgResp.OrganizationId),
+ },
+ },
+ func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) {
+ infos := make([]userAttr, len(usernames))
+ for i, username := range usernames {
+ resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username)
+ infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()}
+ }
+ request.Queries = append(request.Queries, InUserEmailsQuery(usernames))
+ return infos, nil
+ },
+ },
+ want: &user.ListUsersResponse{
+ Details: &object_v2beta.ListDetails{
+ TotalResult: 3,
+ Timestamp: timestamppb.Now(),
+ },
+ SortingColumn: 0,
+ Result: []*user.User{
+ {
+ State: user.UserState_USER_STATE_ACTIVE,
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "Mickey",
+ FamilyName: "Mouse",
+ NickName: gu.Ptr("Mickey"),
+ DisplayName: gu.Ptr("Mickey Mouse"),
+ PreferredLanguage: gu.Ptr("nl"),
+ Gender: user.Gender_GENDER_MALE.Enum(),
+ },
+ Email: &user.HumanEmail{
+ IsVerified: true,
+ },
+ Phone: &user.HumanPhone{
+ Phone: "+41791234567",
+ IsVerified: true,
+ },
+ },
+ },
+ }, {
+ State: user.UserState_USER_STATE_ACTIVE,
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "Mickey",
+ FamilyName: "Mouse",
+ NickName: gu.Ptr("Mickey"),
+ DisplayName: gu.Ptr("Mickey Mouse"),
+ PreferredLanguage: gu.Ptr("nl"),
+ Gender: user.Gender_GENDER_MALE.Enum(),
+ },
+ Email: &user.HumanEmail{
+ IsVerified: true,
+ },
+ Phone: &user.HumanPhone{
+ Phone: "+41791234567",
+ IsVerified: true,
+ },
+ },
+ },
+ }, {
+ State: user.UserState_USER_STATE_ACTIVE,
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "Mickey",
+ FamilyName: "Mouse",
+ NickName: gu.Ptr("Mickey"),
+ DisplayName: gu.Ptr("Mickey Mouse"),
+ PreferredLanguage: gu.Ptr("nl"),
+ Gender: user.Gender_GENDER_MALE.Enum(),
+ },
+ Email: &user.HumanEmail{
+ IsVerified: true,
+ },
+ Phone: &user.HumanPhone{
+ Phone: "+41791234567",
+ IsVerified: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "list user in emails no found, ok",
+ args: args{
+ IamCTX,
+ 3,
+ &user.ListUsersRequest{Queries: []*user.SearchQuery{
+ OrganizationIdQuery(orgResp.OrganizationId),
+ InUserEmailsQuery([]string{"notfound"}),
+ },
+ },
+ func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) {
+ return []userAttr{}, nil
+ },
+ },
+ want: &user.ListUsersResponse{
+ Details: &object_v2beta.ListDetails{
+ TotalResult: 0,
+ Timestamp: timestamppb.Now(),
+ },
+ SortingColumn: 0,
+ Result: []*user.User{},
+ },
+ },
+ {
+ name: "list user resourceowner multiple, ok",
+ args: args{
+ IamCTX,
+ 3,
+ &user.ListUsersRequest{},
+ func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) {
+ orgResp := Tester.CreateOrganization(ctx, fmt.Sprintf("ListUsersResourceowner%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()))
+
+ infos := make([]userAttr, len(usernames))
+ for i, username := range usernames {
+ resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username)
+ infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()}
+ }
+ request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId))
+ request.Queries = append(request.Queries, InUserEmailsQuery(usernames))
+ return infos, nil
+ },
+ },
+ want: &user.ListUsersResponse{
+ Details: &object_v2beta.ListDetails{
+ TotalResult: 3,
+ Timestamp: timestamppb.Now(),
+ },
+ SortingColumn: 0,
+ Result: []*user.User{
+ {
+ State: user.UserState_USER_STATE_ACTIVE,
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "Mickey",
+ FamilyName: "Mouse",
+ NickName: gu.Ptr("Mickey"),
+ DisplayName: gu.Ptr("Mickey Mouse"),
+ PreferredLanguage: gu.Ptr("nl"),
+ Gender: user.Gender_GENDER_MALE.Enum(),
+ },
+ Email: &user.HumanEmail{
+ IsVerified: true,
+ },
+ Phone: &user.HumanPhone{
+ Phone: "+41791234567",
+ IsVerified: true,
+ },
+ },
+ },
+ }, {
+ State: user.UserState_USER_STATE_ACTIVE,
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "Mickey",
+ FamilyName: "Mouse",
+ NickName: gu.Ptr("Mickey"),
+ DisplayName: gu.Ptr("Mickey Mouse"),
+ PreferredLanguage: gu.Ptr("nl"),
+ Gender: user.Gender_GENDER_MALE.Enum(),
+ },
+ Email: &user.HumanEmail{
+ IsVerified: true,
+ },
+ Phone: &user.HumanPhone{
+ Phone: "+41791234567",
+ IsVerified: true,
+ },
+ },
+ },
+ }, {
+ State: user.UserState_USER_STATE_ACTIVE,
+ Type: &user.User_Human{
+ Human: &user.HumanUser{
+ Profile: &user.HumanProfile{
+ GivenName: "Mickey",
+ FamilyName: "Mouse",
+ NickName: gu.Ptr("Mickey"),
+ DisplayName: gu.Ptr("Mickey Mouse"),
+ PreferredLanguage: gu.Ptr("nl"),
+ Gender: user.Gender_GENDER_MALE.Enum(),
+ },
+ Email: &user.HumanEmail{
+ IsVerified: true,
+ },
+ Phone: &user.HumanPhone{
+ Phone: "+41791234567",
+ IsVerified: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ usernames := make([]string, tt.args.count)
+ for i := 0; i < tt.args.count; i++ {
+ usernames[i] = fmt.Sprintf("%d%d@mouse.com", time.Now().UnixNano(), i)
+ }
+ infos, err := tt.args.dep(tt.args.ctx, usernames, tt.args.req)
+ require.NoError(t, err)
+ retryDuration := time.Minute
+ if ctxDeadline, ok := CTX.Deadline(); ok {
+ retryDuration = time.Until(ctxDeadline)
+ }
+ require.EventuallyWithT(t, func(ttt *assert.CollectT) {
+ got, listErr := Client.ListUsers(tt.args.ctx, tt.args.req)
+ assertErr := assert.NoError
+ if tt.wantErr {
+ assertErr = assert.Error
+ }
+ assertErr(ttt, listErr)
+ if listErr != nil {
+ return
+ }
+ // always only give back dependency infos which are required for the response
+ assert.Len(ttt, tt.want.Result, len(infos))
+ // always first check length, otherwise its failed anyway
+ assert.Len(ttt, got.Result, len(tt.want.Result))
+ // fill in userid and username as it is generated
+ for i := range infos {
+ tt.want.Result[i].UserId = infos[i].UserID
+ tt.want.Result[i].Username = infos[i].Username
+ tt.want.Result[i].PreferredLoginName = infos[i].Username
+ tt.want.Result[i].LoginNames = []string{infos[i].Username}
+ if human := tt.want.Result[i].GetHuman(); human != nil {
+ human.Email.Email = infos[i].Username
+ if tt.want.Result[i].GetHuman().GetPasswordChanged() != nil {
+ human.PasswordChanged = infos[i].Changed
+ }
+ }
+ tt.want.Result[i].Details = detailsV2ToV2beta(infos[i].Details)
+ }
+ for i := range tt.want.Result {
+ assert.Contains(ttt, got.Result, tt.want.Result[i])
+ }
+ integration.AssertListDetails(t, tt.want, got)
+ }, retryDuration, time.Millisecond*100, "timeout waiting for expected user result")
+ })
+ }
+}
+
+func InUserIDsQuery(ids []string) *user.SearchQuery {
+ return &user.SearchQuery{Query: &user.SearchQuery_InUserIdsQuery{
+ InUserIdsQuery: &user.InUserIDQuery{
+ UserIds: ids,
+ },
+ },
+ }
+}
+
+func InUserEmailsQuery(emails []string) *user.SearchQuery {
+ return &user.SearchQuery{Query: &user.SearchQuery_InUserEmailsQuery{
+ InUserEmailsQuery: &user.InUserEmailsQuery{
+ UserEmails: emails,
+ },
+ },
+ }
+}
+
+func UsernameQuery(username string) *user.SearchQuery {
+ return &user.SearchQuery{Query: &user.SearchQuery_UserNameQuery{
+ UserNameQuery: &user.UserNameQuery{
+ UserName: username,
+ },
+ },
+ }
+}
+
+func OrganizationIdQuery(resourceowner string) *user.SearchQuery {
+ return &user.SearchQuery{Query: &user.SearchQuery_OrganizationIdQuery{
+ OrganizationIdQuery: &user.OrganizationIdQuery{
+ OrganizationId: resourceowner,
+ },
+ },
+ }
+}
diff --git a/internal/api/grpc/user/v2beta/server.go b/internal/api/grpc/user/v2beta/server.go
new file mode 100644
index 0000000000..93af47f58b
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/server.go
@@ -0,0 +1,75 @@
+package user
+
+import (
+ "context"
+
+ "google.golang.org/grpc"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
+ "github.com/zitadel/zitadel/internal/api/grpc/server"
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/crypto"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/query"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+var _ user.UserServiceServer = (*Server)(nil)
+
+type Server struct {
+ user.UnimplementedUserServiceServer
+ command *command.Commands
+ query *query.Queries
+ userCodeAlg crypto.EncryptionAlgorithm
+ idpAlg crypto.EncryptionAlgorithm
+ idpCallback func(ctx context.Context) string
+ samlRootURL func(ctx context.Context, idpID string) string
+
+ assetAPIPrefix func(context.Context) string
+
+ checkPermission domain.PermissionCheck
+}
+
+type Config struct{}
+
+func CreateServer(
+ command *command.Commands,
+ query *query.Queries,
+ userCodeAlg crypto.EncryptionAlgorithm,
+ idpAlg crypto.EncryptionAlgorithm,
+ idpCallback func(ctx context.Context) string,
+ samlRootURL func(ctx context.Context, idpID string) string,
+ assetAPIPrefix func(ctx context.Context) string,
+ checkPermission domain.PermissionCheck,
+) *Server {
+ return &Server{
+ command: command,
+ query: query,
+ userCodeAlg: userCodeAlg,
+ idpAlg: idpAlg,
+ idpCallback: idpCallback,
+ samlRootURL: samlRootURL,
+ assetAPIPrefix: assetAPIPrefix,
+ checkPermission: checkPermission,
+ }
+}
+
+func (s *Server) RegisterServer(grpcServer *grpc.Server) {
+ user.RegisterUserServiceServer(grpcServer, s)
+}
+
+func (s *Server) AppName() string {
+ return user.UserService_ServiceDesc.ServiceName
+}
+
+func (s *Server) MethodPrefix() string {
+ return user.UserService_ServiceDesc.ServiceName
+}
+
+func (s *Server) AuthMethods() authz.MethodMapping {
+ return user.UserService_AuthMethods
+}
+
+func (s *Server) RegisterGateway() server.RegisterGatewayFunc {
+ return user.RegisterUserServiceHandler
+}
diff --git a/internal/api/grpc/user/v2beta/totp.go b/internal/api/grpc/user/v2beta/totp.go
new file mode 100644
index 0000000000..2ef47a9817
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/totp.go
@@ -0,0 +1,44 @@
+package user
+
+import (
+ "context"
+
+ object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/internal/domain"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func (s *Server) RegisterTOTP(ctx context.Context, req *user.RegisterTOTPRequest) (*user.RegisterTOTPResponse, error) {
+ return totpDetailsToPb(
+ s.command.AddUserTOTP(ctx, req.GetUserId(), ""),
+ )
+}
+
+func totpDetailsToPb(totp *domain.TOTP, err error) (*user.RegisterTOTPResponse, error) {
+ if err != nil {
+ return nil, err
+ }
+ return &user.RegisterTOTPResponse{
+ Details: object.DomainToDetailsPb(totp.ObjectDetails),
+ Uri: totp.URI,
+ Secret: totp.Secret,
+ }, nil
+}
+
+func (s *Server) VerifyTOTPRegistration(ctx context.Context, req *user.VerifyTOTPRegistrationRequest) (*user.VerifyTOTPRegistrationResponse, error) {
+ objectDetails, err := s.command.CheckUserTOTP(ctx, req.GetUserId(), req.GetCode(), "")
+ if err != nil {
+ return nil, err
+ }
+ return &user.VerifyTOTPRegistrationResponse{
+ Details: object.DomainToDetailsPb(objectDetails),
+ }, nil
+}
+
+func (s *Server) RemoveTOTP(ctx context.Context, req *user.RemoveTOTPRequest) (*user.RemoveTOTPResponse, error) {
+ objectDetails, err := s.command.HumanRemoveTOTP(ctx, req.GetUserId(), "")
+ if err != nil {
+ return nil, err
+ }
+ return &user.RemoveTOTPResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil
+}
diff --git a/internal/api/grpc/user/v2beta/totp_integration_test.go b/internal/api/grpc/user/v2beta/totp_integration_test.go
new file mode 100644
index 0000000000..47b2952afd
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/totp_integration_test.go
@@ -0,0 +1,284 @@
+//go:build integration
+
+package user_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/pquerna/otp/totp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/integration"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func TestServer_RegisterTOTP(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ Tester.RegisterUserPasskey(CTX, userID)
+ _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID)
+ ctx := Tester.WithAuthorizationToken(CTX, sessionToken)
+
+ otherUser := Tester.CreateHumanUser(CTX).GetUserId()
+ Tester.RegisterUserPasskey(CTX, otherUser)
+ _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser)
+ ctxOtherUser := Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser)
+
+ type args struct {
+ ctx context.Context
+ req *user.RegisterTOTPRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.RegisterTOTPResponse
+ wantErr bool
+ }{
+ {
+ name: "missing user id",
+ args: args{
+ ctx: ctx,
+ req: &user.RegisterTOTPRequest{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "user mismatch",
+ args: args{
+ ctx: ctxOtherUser,
+ req: &user.RegisterTOTPRequest{
+ UserId: userID,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "admin",
+ args: args{
+ ctx: CTX,
+ req: &user.RegisterTOTPRequest{
+ UserId: userID,
+ },
+ },
+ want: &user.RegisterTOTPResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "success",
+ args: args{
+ ctx: ctx,
+ req: &user.RegisterTOTPRequest{
+ UserId: userID,
+ },
+ },
+ want: &user.RegisterTOTPResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.RegisterTOTP(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ integration.AssertDetails(t, tt.want, got)
+ assert.NotEmpty(t, got.GetUri())
+ assert.NotEmpty(t, got.GetSecret())
+ })
+ }
+}
+
+func TestServer_VerifyTOTPRegistration(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ Tester.RegisterUserPasskey(CTX, userID)
+ _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID)
+ ctx := Tester.WithAuthorizationToken(CTX, sessionToken)
+
+ reg, err := Client.RegisterTOTP(ctx, &user.RegisterTOTPRequest{
+ UserId: userID,
+ })
+ require.NoError(t, err)
+ code, err := totp.GenerateCode(reg.Secret, time.Now())
+ require.NoError(t, err)
+
+ otherUser := Tester.CreateHumanUser(CTX).GetUserId()
+ Tester.RegisterUserPasskey(CTX, otherUser)
+ _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser)
+ ctxOtherUser := Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser)
+
+ regOtherUser, err := Client.RegisterTOTP(CTX, &user.RegisterTOTPRequest{
+ UserId: otherUser,
+ })
+ require.NoError(t, err)
+ codeOtherUser, err := totp.GenerateCode(regOtherUser.Secret, time.Now())
+ require.NoError(t, err)
+
+ type args struct {
+ ctx context.Context
+ req *user.VerifyTOTPRegistrationRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.VerifyTOTPRegistrationResponse
+ wantErr bool
+ }{
+ {
+ name: "user mismatch",
+ args: args{
+ ctx: ctxOtherUser,
+ req: &user.VerifyTOTPRegistrationRequest{
+ UserId: userID,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "wrong code",
+ args: args{
+ ctx: ctx,
+ req: &user.VerifyTOTPRegistrationRequest{
+ UserId: userID,
+ Code: "123",
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "success",
+ args: args{
+ ctx: ctx,
+ req: &user.VerifyTOTPRegistrationRequest{
+ UserId: userID,
+ Code: code,
+ },
+ },
+ want: &user.VerifyTOTPRegistrationResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ResourceOwner,
+ },
+ },
+ },
+ {
+ name: "success, admin",
+ args: args{
+ ctx: CTX,
+ req: &user.VerifyTOTPRegistrationRequest{
+ UserId: otherUser,
+ Code: codeOtherUser,
+ },
+ },
+ want: &user.VerifyTOTPRegistrationResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ResourceOwner,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.VerifyTOTPRegistration(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_RemoveTOTP(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ Tester.RegisterUserPasskey(CTX, userID)
+ _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID)
+
+ userVerified := Tester.CreateHumanUser(CTX)
+ Tester.RegisterUserPasskey(CTX, userVerified.GetUserId())
+ _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId())
+ userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified)
+ _, err := Client.VerifyPhone(userVerifiedCtx, &user.VerifyPhoneRequest{
+ UserId: userVerified.GetUserId(),
+ VerificationCode: userVerified.GetPhoneCode(),
+ })
+ require.NoError(t, err)
+
+ regOtherUser, err := Client.RegisterTOTP(CTX, &user.RegisterTOTPRequest{
+ UserId: userVerified.GetUserId(),
+ })
+ require.NoError(t, err)
+ codeOtherUser, err := totp.GenerateCode(regOtherUser.Secret, time.Now())
+ require.NoError(t, err)
+ _, err = Client.VerifyTOTPRegistration(userVerifiedCtx, &user.VerifyTOTPRegistrationRequest{
+ UserId: userVerified.GetUserId(),
+ Code: codeOtherUser,
+ },
+ )
+ require.NoError(t, err)
+
+ type args struct {
+ ctx context.Context
+ req *user.RemoveTOTPRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.RemoveTOTPResponse
+ wantErr bool
+ }{
+ {
+ name: "not added",
+ args: args{
+ ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken),
+ req: &user.RemoveTOTPRequest{
+ UserId: userID,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "success",
+ args: args{
+ ctx: userVerifiedCtx,
+ req: &user.RemoveTOTPRequest{
+ UserId: userVerified.GetUserId(),
+ },
+ },
+ want: &user.RemoveTOTPResponse{
+ Details: &object.Details{
+ ResourceOwner: Tester.Organisation.ResourceOwner,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.RemoveTOTP(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
diff --git a/internal/api/grpc/user/v2beta/totp_test.go b/internal/api/grpc/user/v2beta/totp_test.go
new file mode 100644
index 0000000000..81a54675f2
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/totp_test.go
@@ -0,0 +1,71 @@
+package user
+
+import (
+ "io"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/domain"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func Test_totpDetailsToPb(t *testing.T) {
+ type args struct {
+ otp *domain.TOTP
+ err error
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.RegisterTOTPResponse
+ wantErr error
+ }{
+ {
+ name: "error",
+ args: args{
+ err: io.ErrClosedPipe,
+ },
+ wantErr: io.ErrClosedPipe,
+ },
+ {
+ name: "success",
+ args: args{
+ otp: &domain.TOTP{
+ ObjectDetails: &domain.ObjectDetails{
+ Sequence: 123,
+ EventDate: time.Unix(456, 789),
+ ResourceOwner: "me",
+ },
+ Secret: "secret",
+ URI: "URI",
+ },
+ },
+ want: &user.RegisterTOTPResponse{
+ Details: &object.Details{
+ Sequence: 123,
+ ChangeDate: ×tamppb.Timestamp{
+ Seconds: 456,
+ Nanos: 789,
+ },
+ ResourceOwner: "me",
+ },
+ Secret: "secret",
+ Uri: "URI",
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := totpDetailsToPb(tt.args.otp, tt.args.err)
+ require.ErrorIs(t, err, tt.wantErr)
+ if !proto.Equal(tt.want, got) {
+ t.Errorf("RegisterTOTPResponse =\n%v\nwant\n%v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/api/grpc/user/v2beta/u2f.go b/internal/api/grpc/user/v2beta/u2f.go
new file mode 100644
index 0000000000..e23a22b8b5
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/u2f.go
@@ -0,0 +1,42 @@
+package user
+
+import (
+ "context"
+
+ object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/zerrors"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func (s *Server) RegisterU2F(ctx context.Context, req *user.RegisterU2FRequest) (*user.RegisterU2FResponse, error) {
+ return u2fRegistrationDetailsToPb(
+ s.command.RegisterUserU2F(ctx, req.GetUserId(), "", req.GetDomain()),
+ )
+}
+
+func u2fRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterU2FResponse, error) {
+ objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err)
+ if err != nil {
+ return nil, err
+ }
+ return &user.RegisterU2FResponse{
+ Details: objectDetails,
+ U2FId: details.ID,
+ PublicKeyCredentialCreationOptions: options,
+ }, nil
+}
+
+func (s *Server) VerifyU2FRegistration(ctx context.Context, req *user.VerifyU2FRegistrationRequest) (*user.VerifyU2FRegistrationResponse, error) {
+ pkc, err := req.GetPublicKeyCredential().MarshalJSON()
+ if err != nil {
+ return nil, zerrors.ThrowInternal(err, "USERv2-IeTh4", "Errors.Internal")
+ }
+ objectDetails, err := s.command.HumanVerifyU2FSetup(ctx, req.GetUserId(), "", req.GetTokenName(), "", pkc)
+ if err != nil {
+ return nil, err
+ }
+ return &user.VerifyU2FRegistrationResponse{
+ Details: object.DomainToDetailsPb(objectDetails),
+ }, nil
+}
diff --git a/internal/api/grpc/user/v2beta/u2f_integration_test.go b/internal/api/grpc/user/v2beta/u2f_integration_test.go
new file mode 100644
index 0000000000..3b7fbd293c
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/u2f_integration_test.go
@@ -0,0 +1,190 @@
+//go:build integration
+
+package user_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/structpb"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/integration"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func TestServer_RegisterU2F(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ otherUser := Tester.CreateHumanUser(CTX).GetUserId()
+
+ // We also need a user session
+ Tester.RegisterUserPasskey(CTX, userID)
+ _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID)
+ Tester.RegisterUserPasskey(CTX, otherUser)
+ _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser)
+
+ type args struct {
+ ctx context.Context
+ req *user.RegisterU2FRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.RegisterU2FResponse
+ wantErr bool
+ }{
+ {
+ name: "missing user id",
+ args: args{
+ ctx: CTX,
+ req: &user.RegisterU2FRequest{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "admin user",
+ args: args{
+ ctx: CTX,
+ req: &user.RegisterU2FRequest{
+ UserId: userID,
+ },
+ },
+ want: &user.RegisterU2FResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "other user, no permission",
+ args: args{
+ ctx: Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser),
+ req: &user.RegisterU2FRequest{
+ UserId: userID,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "user setting its own passkey",
+ args: args{
+ ctx: Tester.WithAuthorizationToken(CTX, sessionToken),
+ req: &user.RegisterU2FRequest{
+ UserId: userID,
+ },
+ },
+ want: &user.RegisterU2FResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.RegisterU2F(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ integration.AssertDetails(t, tt.want, got)
+ if tt.want != nil {
+ assert.NotEmpty(t, got.GetU2FId())
+ assert.NotEmpty(t, got.GetPublicKeyCredentialCreationOptions())
+ _, err = Tester.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions())
+ require.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestServer_VerifyU2FRegistration(t *testing.T) {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ Tester.RegisterUserPasskey(CTX, userID)
+ _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID)
+ ctx := Tester.WithAuthorizationToken(CTX, sessionToken)
+
+ pkr, err := Client.RegisterU2F(ctx, &user.RegisterU2FRequest{
+ UserId: userID,
+ })
+ require.NoError(t, err)
+ require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions())
+
+ attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions())
+ require.NoError(t, err)
+
+ type args struct {
+ ctx context.Context
+ req *user.VerifyU2FRegistrationRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.VerifyU2FRegistrationResponse
+ wantErr bool
+ }{
+ {
+ name: "missing user id",
+ args: args{
+ ctx: ctx,
+ req: &user.VerifyU2FRegistrationRequest{
+ U2FId: "123",
+ TokenName: "nice name",
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "success",
+ args: args{
+ ctx: ctx,
+ req: &user.VerifyU2FRegistrationRequest{
+ UserId: userID,
+ U2FId: pkr.GetU2FId(),
+ PublicKeyCredential: attestationResponse,
+ TokenName: "nice name",
+ },
+ },
+ want: &user.VerifyU2FRegistrationResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "wrong credential",
+ args: args{
+ ctx: ctx,
+ req: &user.VerifyU2FRegistrationRequest{
+ UserId: userID,
+ U2FId: "123",
+ PublicKeyCredential: &structpb.Struct{
+ Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}},
+ },
+ TokenName: "nice name",
+ },
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.VerifyU2FRegistration(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
diff --git a/internal/api/grpc/user/v2beta/u2f_test.go b/internal/api/grpc/user/v2beta/u2f_test.go
new file mode 100644
index 0000000000..087837ce3c
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/u2f_test.go
@@ -0,0 +1,97 @@
+package user
+
+import (
+ "io"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/types/known/structpb"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/api/grpc"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/zerrors"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func Test_u2fRegistrationDetailsToPb(t *testing.T) {
+ type args struct {
+ details *domain.WebAuthNRegistrationDetails
+ err error
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.RegisterU2FResponse
+ wantErr error
+ }{
+ {
+ name: "an error",
+ args: args{
+ details: nil,
+ err: io.ErrClosedPipe,
+ },
+ wantErr: io.ErrClosedPipe,
+ },
+ {
+ name: "unmarshall error",
+ args: args{
+ details: &domain.WebAuthNRegistrationDetails{
+ ObjectDetails: &domain.ObjectDetails{
+ Sequence: 22,
+ EventDate: time.Unix(3000, 22),
+ ResourceOwner: "me",
+ },
+ ID: "123",
+ PublicKeyCredentialCreationOptions: []byte(`\\`),
+ },
+ err: nil,
+ },
+ wantErr: zerrors.ThrowInternal(nil, "USERv2-Dohr6", "Errors.Internal"),
+ },
+ {
+ name: "ok",
+ args: args{
+ details: &domain.WebAuthNRegistrationDetails{
+ ObjectDetails: &domain.ObjectDetails{
+ Sequence: 22,
+ EventDate: time.Unix(3000, 22),
+ ResourceOwner: "me",
+ },
+ ID: "123",
+ PublicKeyCredentialCreationOptions: []byte(`{"foo": "bar"}`),
+ },
+ err: nil,
+ },
+ want: &user.RegisterU2FResponse{
+ Details: &object.Details{
+ Sequence: 22,
+ ChangeDate: ×tamppb.Timestamp{
+ Seconds: 3000,
+ Nanos: 22,
+ },
+ ResourceOwner: "me",
+ },
+ U2FId: "123",
+ PublicKeyCredentialCreationOptions: &structpb.Struct{
+ Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}},
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := u2fRegistrationDetailsToPb(tt.args.details, tt.args.err)
+ require.ErrorIs(t, err, tt.wantErr)
+ if !proto.Equal(tt.want, got) {
+ t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got)
+ }
+ if tt.want != nil {
+ grpc.AllFieldsSet(t, got.ProtoReflect())
+ }
+ })
+ }
+}
diff --git a/internal/api/grpc/user/v2beta/user.go b/internal/api/grpc/user/v2beta/user.go
new file mode 100644
index 0000000000..8e3151a0b0
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/user.go
@@ -0,0 +1,633 @@
+package user
+
+import (
+ "context"
+ "errors"
+ "io"
+
+ "golang.org/x/text/language"
+ "google.golang.org/protobuf/types/known/structpb"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/api/authz"
+ object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta"
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/crypto"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/idp"
+ "github.com/zitadel/zitadel/internal/idp/providers/ldap"
+ "github.com/zitadel/zitadel/internal/query"
+ "github.com/zitadel/zitadel/internal/zerrors"
+ object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) {
+ human, err := AddUserRequestToAddHuman(req)
+ if err != nil {
+ return nil, err
+ }
+ orgID := authz.GetCtxData(ctx).OrgID
+ if err = s.command.AddUserHuman(ctx, orgID, human, false, s.userCodeAlg); err != nil {
+ return nil, err
+ }
+ return &user.AddHumanUserResponse{
+ UserId: human.ID,
+ Details: object.DomainToDetailsPb(human.Details),
+ EmailCode: human.EmailCode,
+ PhoneCode: human.PhoneCode,
+ }, nil
+}
+
+func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, error) {
+ username := req.GetUsername()
+ if username == "" {
+ username = req.GetEmail().GetEmail()
+ }
+ var urlTemplate string
+ if req.GetEmail().GetSendCode() != nil {
+ urlTemplate = req.GetEmail().GetSendCode().GetUrlTemplate()
+ // test the template execution so the async notification will not fail because of it and the user won't realize
+ if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, req.GetUserId(), "code", "orgID"); err != nil {
+ return nil, err
+ }
+ }
+ passwordChangeRequired := req.GetPassword().GetChangeRequired() || req.GetHashedPassword().GetChangeRequired()
+ metadata := make([]*command.AddMetadataEntry, len(req.Metadata))
+ for i, metadataEntry := range req.Metadata {
+ metadata[i] = &command.AddMetadataEntry{
+ Key: metadataEntry.GetKey(),
+ Value: metadataEntry.GetValue(),
+ }
+ }
+ links := make([]*command.AddLink, len(req.GetIdpLinks()))
+ for i, link := range req.GetIdpLinks() {
+ links[i] = &command.AddLink{
+ IDPID: link.GetIdpId(),
+ IDPExternalID: link.GetUserId(),
+ DisplayName: link.GetUserName(),
+ }
+ }
+ return &command.AddHuman{
+ ID: req.GetUserId(),
+ Username: username,
+ FirstName: req.GetProfile().GetGivenName(),
+ LastName: req.GetProfile().GetFamilyName(),
+ NickName: req.GetProfile().GetNickName(),
+ DisplayName: req.GetProfile().GetDisplayName(),
+ Email: command.Email{
+ Address: domain.EmailAddress(req.GetEmail().GetEmail()),
+ Verified: req.GetEmail().GetIsVerified(),
+ ReturnCode: req.GetEmail().GetReturnCode() != nil,
+ URLTemplate: urlTemplate,
+ },
+ Phone: command.Phone{
+ Number: domain.PhoneNumber(req.GetPhone().GetPhone()),
+ Verified: req.GetPhone().GetIsVerified(),
+ ReturnCode: req.GetPhone().GetReturnCode() != nil,
+ },
+ PreferredLanguage: language.Make(req.GetProfile().GetPreferredLanguage()),
+ Gender: genderToDomain(req.GetProfile().GetGender()),
+ Password: req.GetPassword().GetPassword(),
+ EncodedPasswordHash: req.GetHashedPassword().GetHash(),
+ PasswordChangeRequired: passwordChangeRequired,
+ Passwordless: false,
+ Register: false,
+ Metadata: metadata,
+ Links: links,
+ TOTPSecret: req.GetTotpSecret(),
+ }, nil
+}
+
+func genderToDomain(gender user.Gender) domain.Gender {
+ switch gender {
+ case user.Gender_GENDER_UNSPECIFIED:
+ return domain.GenderUnspecified
+ case user.Gender_GENDER_FEMALE:
+ return domain.GenderFemale
+ case user.Gender_GENDER_MALE:
+ return domain.GenderMale
+ case user.Gender_GENDER_DIVERSE:
+ return domain.GenderDiverse
+ default:
+ return domain.GenderUnspecified
+ }
+}
+
+func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserRequest) (_ *user.UpdateHumanUserResponse, err error) {
+ human, err := UpdateUserRequestToChangeHuman(req)
+ if err != nil {
+ return nil, err
+ }
+ err = s.command.ChangeUserHuman(ctx, human, s.userCodeAlg)
+ if err != nil {
+ return nil, err
+ }
+ return &user.UpdateHumanUserResponse{
+ Details: object.DomainToDetailsPb(human.Details),
+ EmailCode: human.EmailCode,
+ PhoneCode: human.PhoneCode,
+ }, nil
+}
+
+func (s *Server) LockUser(ctx context.Context, req *user.LockUserRequest) (_ *user.LockUserResponse, err error) {
+ details, err := s.command.LockUserV2(ctx, req.UserId)
+ if err != nil {
+ return nil, err
+ }
+ return &user.LockUserResponse{
+ Details: object.DomainToDetailsPb(details),
+ }, nil
+}
+
+func (s *Server) UnlockUser(ctx context.Context, req *user.UnlockUserRequest) (_ *user.UnlockUserResponse, err error) {
+ details, err := s.command.UnlockUserV2(ctx, req.UserId)
+ if err != nil {
+ return nil, err
+ }
+ return &user.UnlockUserResponse{
+ Details: object.DomainToDetailsPb(details),
+ }, nil
+}
+
+func (s *Server) DeactivateUser(ctx context.Context, req *user.DeactivateUserRequest) (_ *user.DeactivateUserResponse, err error) {
+ details, err := s.command.DeactivateUserV2(ctx, req.UserId)
+ if err != nil {
+ return nil, err
+ }
+ return &user.DeactivateUserResponse{
+ Details: object.DomainToDetailsPb(details),
+ }, nil
+}
+
+func (s *Server) ReactivateUser(ctx context.Context, req *user.ReactivateUserRequest) (_ *user.ReactivateUserResponse, err error) {
+ details, err := s.command.ReactivateUserV2(ctx, req.UserId)
+ if err != nil {
+ return nil, err
+ }
+ return &user.ReactivateUserResponse{
+ Details: object.DomainToDetailsPb(details),
+ }, nil
+}
+
+func ifNotNilPtr[v, p any](value *v, conv func(v) p) *p {
+ var pNil *p
+ if value == nil {
+ return pNil
+ }
+ pVal := conv(*value)
+ return &pVal
+}
+
+func UpdateUserRequestToChangeHuman(req *user.UpdateHumanUserRequest) (*command.ChangeHuman, error) {
+ email, err := SetHumanEmailToEmail(req.Email, req.GetUserId())
+ if err != nil {
+ return nil, err
+ }
+ return &command.ChangeHuman{
+ ID: req.GetUserId(),
+ Username: req.Username,
+ Profile: SetHumanProfileToProfile(req.Profile),
+ Email: email,
+ Phone: SetHumanPhoneToPhone(req.Phone),
+ Password: SetHumanPasswordToPassword(req.Password),
+ }, nil
+}
+
+func SetHumanProfileToProfile(profile *user.SetHumanProfile) *command.Profile {
+ if profile == nil {
+ return nil
+ }
+ var firstName *string
+ if profile.GivenName != "" {
+ firstName = &profile.GivenName
+ }
+ var lastName *string
+ if profile.FamilyName != "" {
+ lastName = &profile.FamilyName
+ }
+ return &command.Profile{
+ FirstName: firstName,
+ LastName: lastName,
+ NickName: profile.NickName,
+ DisplayName: profile.DisplayName,
+ PreferredLanguage: ifNotNilPtr(profile.PreferredLanguage, language.Make),
+ Gender: ifNotNilPtr(profile.Gender, genderToDomain),
+ }
+}
+
+func SetHumanEmailToEmail(email *user.SetHumanEmail, userID string) (*command.Email, error) {
+ if email == nil {
+ return nil, nil
+ }
+ var urlTemplate string
+ if email.GetSendCode() != nil && email.GetSendCode().UrlTemplate != nil {
+ urlTemplate = *email.GetSendCode().UrlTemplate
+ if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, userID, "code", "orgID"); err != nil {
+ return nil, err
+ }
+ }
+ return &command.Email{
+ Address: domain.EmailAddress(email.Email),
+ Verified: email.GetIsVerified(),
+ ReturnCode: email.GetReturnCode() != nil,
+ URLTemplate: urlTemplate,
+ }, nil
+}
+
+func SetHumanPhoneToPhone(phone *user.SetHumanPhone) *command.Phone {
+ if phone == nil {
+ return nil
+ }
+ return &command.Phone{
+ Number: domain.PhoneNumber(phone.GetPhone()),
+ Verified: phone.GetIsVerified(),
+ ReturnCode: phone.GetReturnCode() != nil,
+ }
+}
+
+func SetHumanPasswordToPassword(password *user.SetPassword) *command.Password {
+ if password == nil {
+ return nil
+ }
+ return &command.Password{
+ PasswordCode: password.GetVerificationCode(),
+ OldPassword: password.GetCurrentPassword(),
+ Password: password.GetPassword().GetPassword(),
+ EncodedPasswordHash: password.GetHashedPassword().GetHash(),
+ ChangeRequired: password.GetPassword().GetChangeRequired() || password.GetHashedPassword().GetChangeRequired(),
+ }
+}
+
+func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ *user.AddIDPLinkResponse, err error) {
+ details, err := s.command.AddUserIDPLink(ctx, req.UserId, "", &command.AddLink{
+ IDPID: req.GetIdpLink().GetIdpId(),
+ DisplayName: req.GetIdpLink().GetUserName(),
+ IDPExternalID: req.GetIdpLink().GetUserId(),
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &user.AddIDPLinkResponse{
+ Details: object.DomainToDetailsPb(details),
+ }, nil
+}
+
+func (s *Server) DeleteUser(ctx context.Context, req *user.DeleteUserRequest) (_ *user.DeleteUserResponse, err error) {
+ memberships, grants, err := s.removeUserDependencies(ctx, req.GetUserId())
+ if err != nil {
+ return nil, err
+ }
+ details, err := s.command.RemoveUserV2(ctx, req.UserId, memberships, grants...)
+ if err != nil {
+ return nil, err
+ }
+ return &user.DeleteUserResponse{
+ Details: object.DomainToDetailsPb(details),
+ }, nil
+}
+
+func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) {
+ userGrantUserQuery, err := query.NewUserGrantUserIDSearchQuery(userID)
+ if err != nil {
+ return nil, nil, err
+ }
+ grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{
+ Queries: []query.SearchQuery{userGrantUserQuery},
+ }, true)
+ if err != nil {
+ return nil, nil, err
+ }
+ membershipsUserQuery, err := query.NewMembershipUserIDQuery(userID)
+ if err != nil {
+ return nil, nil, err
+ }
+ memberships, err := s.query.Memberships(ctx, &query.MembershipSearchQuery{
+ Queries: []query.SearchQuery{membershipsUserQuery},
+ }, false)
+ if err != nil {
+ return nil, nil, err
+ }
+ return cascadingMemberships(memberships.Memberships), userGrantsToIDs(grants.UserGrants), nil
+}
+
+func cascadingMemberships(memberships []*query.Membership) []*command.CascadingMembership {
+ cascades := make([]*command.CascadingMembership, len(memberships))
+ for i, membership := range memberships {
+ cascades[i] = &command.CascadingMembership{
+ UserID: membership.UserID,
+ ResourceOwner: membership.ResourceOwner,
+ IAM: cascadingIAMMembership(membership.IAM),
+ Org: cascadingOrgMembership(membership.Org),
+ Project: cascadingProjectMembership(membership.Project),
+ ProjectGrant: cascadingProjectGrantMembership(membership.ProjectGrant),
+ }
+ }
+ return cascades
+}
+
+func cascadingIAMMembership(membership *query.IAMMembership) *command.CascadingIAMMembership {
+ if membership == nil {
+ return nil
+ }
+ return &command.CascadingIAMMembership{IAMID: membership.IAMID}
+}
+func cascadingOrgMembership(membership *query.OrgMembership) *command.CascadingOrgMembership {
+ if membership == nil {
+ return nil
+ }
+ return &command.CascadingOrgMembership{OrgID: membership.OrgID}
+}
+func cascadingProjectMembership(membership *query.ProjectMembership) *command.CascadingProjectMembership {
+ if membership == nil {
+ return nil
+ }
+ return &command.CascadingProjectMembership{ProjectID: membership.ProjectID}
+}
+func cascadingProjectGrantMembership(membership *query.ProjectGrantMembership) *command.CascadingProjectGrantMembership {
+ if membership == nil {
+ return nil
+ }
+ return &command.CascadingProjectGrantMembership{ProjectID: membership.ProjectID, GrantID: membership.GrantID}
+}
+
+func userGrantsToIDs(userGrants []*query.UserGrant) []string {
+ converted := make([]string, len(userGrants))
+ for i, grant := range userGrants {
+ converted[i] = grant.ID
+ }
+ return converted
+}
+
+func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *user.StartIdentityProviderIntentRequest) (_ *user.StartIdentityProviderIntentResponse, err error) {
+ switch t := req.GetContent().(type) {
+ case *user.StartIdentityProviderIntentRequest_Urls:
+ return s.startIDPIntent(ctx, req.GetIdpId(), t.Urls)
+ case *user.StartIdentityProviderIntentRequest_Ldap:
+ return s.startLDAPIntent(ctx, req.GetIdpId(), t.Ldap)
+ default:
+ return nil, zerrors.ThrowUnimplementedf(nil, "USERv2-S2g21", "type oneOf %T in method StartIdentityProviderIntent not implemented", t)
+ }
+}
+
+func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*user.StartIdentityProviderIntentResponse, error) {
+ intentWriteModel, details, err := s.command.CreateIntent(ctx, idpID, urls.GetSuccessUrl(), urls.GetFailureUrl(), authz.GetInstance(ctx).InstanceID())
+ if err != nil {
+ return nil, err
+ }
+ content, redirect, err := s.command.AuthFromProvider(ctx, idpID, intentWriteModel.AggregateID, s.idpCallback(ctx), s.samlRootURL(ctx, idpID))
+ if err != nil {
+ return nil, err
+ }
+ if redirect {
+ return &user.StartIdentityProviderIntentResponse{
+ Details: object.DomainToDetailsPb(details),
+ NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: content},
+ }, nil
+ } else {
+ return &user.StartIdentityProviderIntentResponse{
+ Details: object.DomainToDetailsPb(details),
+ NextStep: &user.StartIdentityProviderIntentResponse_PostForm{
+ PostForm: []byte(content),
+ },
+ }, nil
+ }
+}
+
+func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*user.StartIdentityProviderIntentResponse, error) {
+ intentWriteModel, details, err := s.command.CreateIntent(ctx, idpID, "", "", authz.GetInstance(ctx).InstanceID())
+ if err != nil {
+ return nil, err
+ }
+ externalUser, userID, attributes, err := s.ldapLogin(ctx, intentWriteModel.IDPID, ldapCredentials.GetUsername(), ldapCredentials.GetPassword())
+ if err != nil {
+ if err := s.command.FailIDPIntent(ctx, intentWriteModel, err.Error()); err != nil {
+ return nil, err
+ }
+ return nil, err
+ }
+ token, err := s.command.SucceedLDAPIDPIntent(ctx, intentWriteModel, externalUser, userID, attributes)
+ if err != nil {
+ return nil, err
+ }
+ return &user.StartIdentityProviderIntentResponse{
+ Details: object.DomainToDetailsPb(details),
+ NextStep: &user.StartIdentityProviderIntentResponse_IdpIntent{
+ IdpIntent: &user.IDPIntent{
+ IdpIntentId: intentWriteModel.AggregateID,
+ IdpIntentToken: token,
+ UserId: userID,
+ },
+ },
+ }, nil
+}
+
+func (s *Server) checkLinkedExternalUser(ctx context.Context, idpID, externalUserID string) (string, error) {
+ idQuery, err := query.NewIDPUserLinkIDPIDSearchQuery(idpID)
+ if err != nil {
+ return "", err
+ }
+ externalIDQuery, err := query.NewIDPUserLinksExternalIDSearchQuery(externalUserID)
+ if err != nil {
+ return "", err
+ }
+ queries := []query.SearchQuery{
+ idQuery, externalIDQuery,
+ }
+ links, err := s.query.IDPUserLinks(ctx, &query.IDPUserLinksSearchQuery{Queries: queries}, false)
+ if err != nil {
+ return "", err
+ }
+ if len(links.Links) == 1 {
+ return links.Links[0].UserID, nil
+ }
+ return "", nil
+}
+
+func (s *Server) ldapLogin(ctx context.Context, idpID, username, password string) (idp.User, string, map[string][]string, error) {
+ provider, err := s.command.GetProvider(ctx, idpID, "", "")
+ if err != nil {
+ return nil, "", nil, err
+ }
+ ldapProvider, ok := provider.(*ldap.Provider)
+ if !ok {
+ return nil, "", nil, zerrors.ThrowInvalidArgument(nil, "IDP-9a02j2n2bh", "Errors.ExternalIDP.IDPTypeNotImplemented")
+ }
+ session := ldapProvider.GetSession(username, password)
+ externalUser, err := session.FetchUser(ctx)
+ if errors.Is(err, ldap.ErrFailedLogin) || errors.Is(err, ldap.ErrNoSingleUser) {
+ return nil, "", nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-nzun2i", "Errors.User.ExternalIDP.LoginFailed")
+ }
+ if err != nil {
+ return nil, "", nil, err
+ }
+ userID, err := s.checkLinkedExternalUser(ctx, idpID, externalUser.GetID())
+ if err != nil {
+ return nil, "", nil, err
+ }
+
+ attributes := make(map[string][]string, 0)
+ for _, item := range session.Entry.Attributes {
+ attributes[item.Name] = item.Values
+ }
+ return externalUser, userID, attributes, nil
+}
+
+func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.RetrieveIdentityProviderIntentRequest) (_ *user.RetrieveIdentityProviderIntentResponse, err error) {
+ intent, err := s.command.GetIntentWriteModel(ctx, req.GetIdpIntentId(), "")
+ if err != nil {
+ return nil, err
+ }
+ if err := s.checkIntentToken(req.GetIdpIntentToken(), intent.AggregateID); err != nil {
+ return nil, err
+ }
+ if intent.State != domain.IDPIntentStateSucceeded {
+ return nil, zerrors.ThrowPreconditionFailed(nil, "IDP-nme4gszsvx", "Errors.Intent.NotSucceeded")
+ }
+ return idpIntentToIDPIntentPb(intent, s.idpAlg)
+}
+
+func idpIntentToIDPIntentPb(intent *command.IDPIntentWriteModel, alg crypto.EncryptionAlgorithm) (_ *user.RetrieveIdentityProviderIntentResponse, err error) {
+ rawInformation := new(structpb.Struct)
+ err = rawInformation.UnmarshalJSON(intent.IDPUser)
+ if err != nil {
+ return nil, err
+ }
+ information := &user.RetrieveIdentityProviderIntentResponse{
+ Details: intentToDetailsPb(intent),
+ IdpInformation: &user.IDPInformation{
+ IdpId: intent.IDPID,
+ UserId: intent.IDPUserID,
+ UserName: intent.IDPUserName,
+ RawInformation: rawInformation,
+ },
+ UserId: intent.UserID,
+ }
+ if intent.IDPIDToken != "" || intent.IDPAccessToken != nil {
+ information.IdpInformation.Access, err = idpOAuthTokensToPb(intent.IDPIDToken, intent.IDPAccessToken, alg)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if intent.IDPEntryAttributes != nil {
+ access, err := IDPEntryAttributesToPb(intent.IDPEntryAttributes)
+ if err != nil {
+ return nil, err
+ }
+ information.IdpInformation.Access = access
+ }
+
+ if intent.Assertion != nil {
+ assertion, err := crypto.Decrypt(intent.Assertion, alg)
+ if err != nil {
+ return nil, err
+ }
+ information.IdpInformation.Access = IDPSAMLResponseToPb(assertion)
+ }
+
+ return information, nil
+}
+
+func idpOAuthTokensToPb(idpIDToken string, idpAccessToken *crypto.CryptoValue, alg crypto.EncryptionAlgorithm) (_ *user.IDPInformation_Oauth, err error) {
+ var idToken *string
+ if idpIDToken != "" {
+ idToken = &idpIDToken
+ }
+ var accessToken string
+ if idpAccessToken != nil {
+ accessToken, err = crypto.DecryptString(idpAccessToken, alg)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return &user.IDPInformation_Oauth{
+ Oauth: &user.IDPOAuthAccessInformation{
+ AccessToken: accessToken,
+ IdToken: idToken,
+ },
+ }, nil
+}
+
+func intentToDetailsPb(intent *command.IDPIntentWriteModel) *object_pb.Details {
+ return &object_pb.Details{
+ Sequence: intent.ProcessedSequence,
+ ChangeDate: timestamppb.New(intent.ChangeDate),
+ ResourceOwner: intent.ResourceOwner,
+ }
+}
+
+func IDPEntryAttributesToPb(entryAttributes map[string][]string) (*user.IDPInformation_Ldap, error) {
+ values := make(map[string]interface{}, 0)
+ for k, v := range entryAttributes {
+ intValues := make([]interface{}, len(v))
+ for i, value := range v {
+ intValues[i] = value
+ }
+ values[k] = intValues
+ }
+ attributes, err := structpb.NewStruct(values)
+ if err != nil {
+ return nil, err
+ }
+ return &user.IDPInformation_Ldap{
+ Ldap: &user.IDPLDAPAccessInformation{
+ Attributes: attributes,
+ },
+ }, nil
+}
+
+func IDPSAMLResponseToPb(assertion []byte) *user.IDPInformation_Saml {
+ return &user.IDPInformation_Saml{
+ Saml: &user.IDPSAMLAccessInformation{
+ Assertion: assertion,
+ },
+ }
+}
+
+func (s *Server) checkIntentToken(token string, intentID string) error {
+ return crypto.CheckToken(s.idpAlg, token, intentID)
+}
+
+func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *user.ListAuthenticationMethodTypesRequest) (*user.ListAuthenticationMethodTypesResponse, error) {
+ authMethods, err := s.query.ListUserAuthMethodTypes(ctx, req.GetUserId(), true)
+ if err != nil {
+ return nil, err
+ }
+ return &user.ListAuthenticationMethodTypesResponse{
+ Details: object.ToListDetails(authMethods.SearchResponse),
+ AuthMethodTypes: authMethodTypesToPb(authMethods.AuthMethodTypes),
+ }, nil
+}
+
+func authMethodTypesToPb(methodTypes []domain.UserAuthMethodType) []user.AuthenticationMethodType {
+ methods := make([]user.AuthenticationMethodType, len(methodTypes))
+ for i, method := range methodTypes {
+ methods[i] = authMethodTypeToPb(method)
+ }
+ return methods
+}
+
+func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.AuthenticationMethodType {
+ switch methodType {
+ case domain.UserAuthMethodTypeTOTP:
+ return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_TOTP
+ case domain.UserAuthMethodTypeU2F:
+ return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_U2F
+ case domain.UserAuthMethodTypePasswordless:
+ return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY
+ case domain.UserAuthMethodTypePassword:
+ return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSWORD
+ case domain.UserAuthMethodTypeIDP:
+ return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP
+ case domain.UserAuthMethodTypeOTPSMS:
+ return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_SMS
+ case domain.UserAuthMethodTypeOTPEmail:
+ return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_EMAIL
+ case domain.UserAuthMethodTypeUnspecified, domain.UserAuthMethodTypeOTP, domain.UserAuthMethodTypePrivateKey:
+ // Handle all remaining cases so the linter succeeds
+ return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED
+ default:
+ return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED
+ }
+}
diff --git a/internal/api/grpc/user/v2beta/user_integration_test.go b/internal/api/grpc/user/v2beta/user_integration_test.go
new file mode 100644
index 0000000000..d808e46c5f
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/user_integration_test.go
@@ -0,0 +1,2521 @@
+//go:build integration
+
+package user_test
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+ "google.golang.org/protobuf/types/known/structpb"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/api/grpc"
+ "github.com/zitadel/zitadel/internal/integration"
+ "github.com/zitadel/zitadel/pkg/grpc/idp"
+ mgmt "github.com/zitadel/zitadel/pkg/grpc/management"
+ object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+var (
+ CTX context.Context
+ IamCTX context.Context
+ UserCTX context.Context
+ SystemCTX context.Context
+ ErrCTX context.Context
+ Tester *integration.Tester
+ Client user.UserServiceClient
+)
+
+func TestMain(m *testing.M) {
+ os.Exit(func() int {
+ ctx, errCtx, cancel := integration.Contexts(time.Hour)
+ defer cancel()
+
+ Tester = integration.NewTester(ctx)
+ defer Tester.Done()
+
+ UserCTX = Tester.WithAuthorization(ctx, integration.Login)
+ IamCTX = Tester.WithAuthorization(ctx, integration.IAMOwner)
+ SystemCTX = Tester.WithAuthorization(ctx, integration.SystemUser)
+ CTX, ErrCTX = Tester.WithAuthorization(ctx, integration.OrgOwner), errCtx
+ Client = Tester.Client.UserV2beta
+ return m.Run()
+ }())
+}
+
+func TestServer_AddHumanUser(t *testing.T) {
+ idpID := Tester.AddGenericOAuthProvider(t, CTX)
+ type args struct {
+ ctx context.Context
+ req *user.AddHumanUserRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.AddHumanUserResponse
+ wantErr bool
+ }{
+ {
+ name: "default verification",
+ args: args{
+ CTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: Tester.Organisation.ID,
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Email: &user.SetHumanEmail{},
+ Phone: &user.SetHumanPhone{},
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_Password{
+ Password: &user.Password{
+ Password: "DifficultPW666!",
+ ChangeRequired: true,
+ },
+ },
+ },
+ },
+ want: &user.AddHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "return email verification code",
+ args: args{
+ CTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: Tester.Organisation.ID,
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Email: &user.SetHumanEmail{
+ Verification: &user.SetHumanEmail_ReturnCode{
+ ReturnCode: &user.ReturnEmailVerificationCode{},
+ },
+ },
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_Password{
+ Password: &user.Password{
+ Password: "DifficultPW666!",
+ ChangeRequired: true,
+ },
+ },
+ },
+ },
+ want: &user.AddHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ EmailCode: gu.Ptr("something"),
+ },
+ },
+ {
+ name: "custom template",
+ args: args{
+ CTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: Tester.Organisation.ID,
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Email: &user.SetHumanEmail{
+ Verification: &user.SetHumanEmail_SendCode{
+ SendCode: &user.SendEmailVerificationCode{
+ UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"),
+ },
+ },
+ },
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_Password{
+ Password: &user.Password{
+ Password: "DifficultPW666!",
+ ChangeRequired: true,
+ },
+ },
+ },
+ },
+ want: &user.AddHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "return phone verification code",
+ args: args{
+ CTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: Tester.Organisation.ID,
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Email: &user.SetHumanEmail{},
+ Phone: &user.SetHumanPhone{
+ Phone: "+41791234567",
+ Verification: &user.SetHumanPhone_ReturnCode{
+ ReturnCode: &user.ReturnPhoneVerificationCode{},
+ },
+ },
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_Password{
+ Password: &user.Password{
+ Password: "DifficultPW666!",
+ ChangeRequired: true,
+ },
+ },
+ },
+ },
+ want: &user.AddHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ PhoneCode: gu.Ptr("something"),
+ },
+ },
+ {
+ name: "custom template error",
+ args: args{
+ CTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: Tester.Organisation.ID,
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Email: &user.SetHumanEmail{
+ Verification: &user.SetHumanEmail_SendCode{
+ SendCode: &user.SendEmailVerificationCode{
+ UrlTemplate: gu.Ptr("{{"),
+ },
+ },
+ },
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_Password{
+ Password: &user.Password{
+ Password: "DifficultPW666!",
+ ChangeRequired: true,
+ },
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "missing REQUIRED profile",
+ args: args{
+ CTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: Tester.Organisation.ID,
+ },
+ },
+ Email: &user.SetHumanEmail{
+ Verification: &user.SetHumanEmail_ReturnCode{
+ ReturnCode: &user.ReturnEmailVerificationCode{},
+ },
+ },
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_Password{
+ Password: &user.Password{
+ Password: "DifficultPW666!",
+ ChangeRequired: true,
+ },
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "missing REQUIRED email",
+ args: args{
+ CTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: Tester.Organisation.ID,
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_Password{
+ Password: &user.Password{
+ Password: "DifficultPW666!",
+ ChangeRequired: true,
+ },
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "missing idp",
+ args: args{
+ CTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: Tester.Organisation.ID,
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Email: &user.SetHumanEmail{
+ Email: "livio@zitadel.com",
+ Verification: &user.SetHumanEmail_IsVerified{
+ IsVerified: true,
+ },
+ },
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_Password{
+ Password: &user.Password{
+ Password: "DifficultPW666!",
+ ChangeRequired: false,
+ },
+ },
+ IdpLinks: []*user.IDPLink{
+ {
+ IdpId: "idpID",
+ UserId: "userID",
+ UserName: "username",
+ },
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "with idp",
+ args: args{
+ CTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: Tester.Organisation.ID,
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Email: &user.SetHumanEmail{
+ Email: "livio@zitadel.com",
+ Verification: &user.SetHumanEmail_IsVerified{
+ IsVerified: true,
+ },
+ },
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_Password{
+ Password: &user.Password{
+ Password: "DifficultPW666!",
+ ChangeRequired: false,
+ },
+ },
+ IdpLinks: []*user.IDPLink{
+ {
+ IdpId: idpID,
+ UserId: "userID",
+ UserName: "username",
+ },
+ },
+ },
+ },
+ want: &user.AddHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "with totp",
+ args: args{
+ CTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: Tester.Organisation.ID,
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Email: &user.SetHumanEmail{
+ Email: "livio@zitadel.com",
+ Verification: &user.SetHumanEmail_IsVerified{
+ IsVerified: true,
+ },
+ },
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_Password{
+ Password: &user.Password{
+ Password: "DifficultPW666!",
+ ChangeRequired: false,
+ },
+ },
+ TotpSecret: gu.Ptr("secret"),
+ },
+ },
+ want: &user.AddHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "password not complexity conform",
+ args: args{
+ CTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: Tester.Organisation.ID,
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Email: &user.SetHumanEmail{},
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_Password{
+ Password: &user.Password{
+ Password: "insufficient",
+ },
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "hashed password",
+ args: args{
+ CTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: Tester.Organisation.ID,
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Email: &user.SetHumanEmail{},
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_HashedPassword{
+ HashedPassword: &user.HashedPassword{
+ Hash: "$2y$12$hXUrnqdq1RIIYZ2HPytIIe5lXdIvbhqrTvdPsSF7o.jFh817Z6lwm",
+ },
+ },
+ },
+ },
+ want: &user.AddHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "unsupported hashed password",
+ args: args{
+ CTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: Tester.Organisation.ID,
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Email: &user.SetHumanEmail{},
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_HashedPassword{
+ HashedPassword: &user.HashedPassword{
+ Hash: "$scrypt$ln=16,r=8,p=1$cmFuZG9tc2FsdGlzaGFyZA$Rh+NnJNo1I6nRwaNqbDm6kmADswD1+7FTKZ7Ln9D8nQ",
+ },
+ },
+ },
+ },
+ wantErr: true,
+ },
+ }
+ for i, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ userID := fmt.Sprint(time.Now().UnixNano() + int64(i))
+ tt.args.req.UserId = &userID
+ if email := tt.args.req.GetEmail(); email != nil {
+ email.Email = fmt.Sprintf("%s@me.now", userID)
+ }
+
+ if tt.want != nil {
+ tt.want.UserId = userID
+ }
+
+ got, err := Client.AddHumanUser(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+
+ assert.Equal(t, tt.want.GetUserId(), got.GetUserId())
+ if tt.want.GetEmailCode() != "" {
+ assert.NotEmpty(t, got.GetEmailCode())
+ }
+ if tt.want.GetPhoneCode() != "" {
+ assert.NotEmpty(t, got.GetPhoneCode())
+ }
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_AddHumanUser_Permission(t *testing.T) {
+ newOrgOwnerEmail := fmt.Sprintf("%d@permission.com", time.Now().UnixNano())
+ newOrg := Tester.CreateOrganization(IamCTX, fmt.Sprintf("AddHuman%d", time.Now().UnixNano()), newOrgOwnerEmail)
+ type args struct {
+ ctx context.Context
+ req *user.AddHumanUserRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.AddHumanUserResponse
+ wantErr bool
+ }{
+ {
+ name: "System, ok",
+ args: args{
+ SystemCTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: newOrg.GetOrganizationId(),
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Email: &user.SetHumanEmail{},
+ Phone: &user.SetHumanPhone{},
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_Password{
+ Password: &user.Password{
+ Password: "DifficultPW666!",
+ ChangeRequired: true,
+ },
+ },
+ },
+ },
+ want: &user.AddHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: newOrg.GetOrganizationId(),
+ },
+ },
+ },
+ {
+ name: "Instance, ok",
+ args: args{
+ IamCTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: newOrg.GetOrganizationId(),
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Email: &user.SetHumanEmail{},
+ Phone: &user.SetHumanPhone{},
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_Password{
+ Password: &user.Password{
+ Password: "DifficultPW666!",
+ ChangeRequired: true,
+ },
+ },
+ },
+ },
+ want: &user.AddHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: newOrg.GetOrganizationId(),
+ },
+ },
+ },
+ {
+ name: "Org, error",
+ args: args{
+ CTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: newOrg.GetOrganizationId(),
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Email: &user.SetHumanEmail{},
+ Phone: &user.SetHumanPhone{},
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_Password{
+ Password: &user.Password{
+ Password: "DifficultPW666!",
+ ChangeRequired: true,
+ },
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "User, error",
+ args: args{
+ UserCTX,
+ &user.AddHumanUserRequest{
+ Organization: &object.Organization{
+ Org: &object.Organization_OrgId{
+ OrgId: newOrg.GetOrganizationId(),
+ },
+ },
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ Email: &user.SetHumanEmail{},
+ Phone: &user.SetHumanPhone{},
+ Metadata: []*user.SetMetadataEntry{
+ {
+ Key: "somekey",
+ Value: []byte("somevalue"),
+ },
+ },
+ PasswordType: &user.AddHumanUserRequest_Password{
+ Password: &user.Password{
+ Password: "DifficultPW666!",
+ ChangeRequired: true,
+ },
+ },
+ },
+ },
+ wantErr: true,
+ },
+ }
+ for i, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ userID := fmt.Sprint(time.Now().UnixNano() + int64(i))
+ tt.args.req.UserId = &userID
+ if email := tt.args.req.GetEmail(); email != nil {
+ email.Email = fmt.Sprintf("%s@me.now", userID)
+ }
+
+ if tt.want != nil {
+ tt.want.UserId = userID
+ }
+
+ got, err := Client.AddHumanUser(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+
+ assert.Equal(t, tt.want.GetUserId(), got.GetUserId())
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_UpdateHumanUser(t *testing.T) {
+ type args struct {
+ ctx context.Context
+ req *user.UpdateHumanUserRequest
+ }
+ tests := []struct {
+ name string
+ prepare func(request *user.UpdateHumanUserRequest) error
+ args args
+ want *user.UpdateHumanUserResponse
+ wantErr bool
+ }{
+ {
+ name: "not exisiting",
+ prepare: func(request *user.UpdateHumanUserRequest) error {
+ request.UserId = "notexisiting"
+ return nil
+ },
+ args: args{
+ CTX,
+ &user.UpdateHumanUserRequest{
+ Username: gu.Ptr("changed"),
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "change username, ok",
+ prepare: func(request *user.UpdateHumanUserRequest) error {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ request.UserId = userID
+ return nil
+ },
+ args: args{
+ CTX,
+ &user.UpdateHumanUserRequest{
+ Username: gu.Ptr(fmt.Sprint(time.Now().UnixNano() + 1)),
+ },
+ },
+ want: &user.UpdateHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "change profile, ok",
+ prepare: func(request *user.UpdateHumanUserRequest) error {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ request.UserId = userID
+ return nil
+ },
+ args: args{
+ CTX,
+ &user.UpdateHumanUserRequest{
+ Profile: &user.SetHumanProfile{
+ GivenName: "Donald",
+ FamilyName: "Duck",
+ NickName: gu.Ptr("Dukkie"),
+ DisplayName: gu.Ptr("Donald Duck"),
+ PreferredLanguage: gu.Ptr("en"),
+ Gender: user.Gender_GENDER_DIVERSE.Enum(),
+ },
+ },
+ },
+ want: &user.UpdateHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "change email, ok",
+ prepare: func(request *user.UpdateHumanUserRequest) error {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ request.UserId = userID
+ return nil
+ },
+ args: args{
+ CTX,
+ &user.UpdateHumanUserRequest{
+ Email: &user.SetHumanEmail{
+ Email: "changed@test.com",
+ Verification: &user.SetHumanEmail_IsVerified{IsVerified: true},
+ },
+ },
+ },
+ want: &user.UpdateHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "change email, code, ok",
+ prepare: func(request *user.UpdateHumanUserRequest) error {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ request.UserId = userID
+ return nil
+ },
+ args: args{
+ CTX,
+ &user.UpdateHumanUserRequest{
+ Email: &user.SetHumanEmail{
+ Email: "changed@test.com",
+ Verification: &user.SetHumanEmail_ReturnCode{},
+ },
+ },
+ },
+ want: &user.UpdateHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ EmailCode: gu.Ptr("something"),
+ },
+ },
+ {
+ name: "change phone, ok",
+ prepare: func(request *user.UpdateHumanUserRequest) error {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ request.UserId = userID
+ return nil
+ },
+ args: args{
+ CTX,
+ &user.UpdateHumanUserRequest{
+ Phone: &user.SetHumanPhone{
+ Phone: "+41791234567",
+ Verification: &user.SetHumanPhone_IsVerified{IsVerified: true},
+ },
+ },
+ },
+ want: &user.UpdateHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "change phone, code, ok",
+ prepare: func(request *user.UpdateHumanUserRequest) error {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ request.UserId = userID
+ return nil
+ },
+ args: args{
+ CTX,
+ &user.UpdateHumanUserRequest{
+ Phone: &user.SetHumanPhone{
+ Phone: "+41791234568",
+ Verification: &user.SetHumanPhone_ReturnCode{},
+ },
+ },
+ },
+ want: &user.UpdateHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ PhoneCode: gu.Ptr("something"),
+ },
+ },
+ {
+ name: "change password, code, ok",
+ prepare: func(request *user.UpdateHumanUserRequest) error {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ request.UserId = userID
+ resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{
+ UserId: userID,
+ Medium: &user.PasswordResetRequest_ReturnCode{
+ ReturnCode: &user.ReturnPasswordResetCode{},
+ },
+ })
+ if err != nil {
+ return err
+ }
+ request.Password.Verification = &user.SetPassword_VerificationCode{
+ VerificationCode: resp.GetVerificationCode(),
+ }
+ return nil
+ },
+ args: args{
+ CTX,
+ &user.UpdateHumanUserRequest{
+ Password: &user.SetPassword{
+ PasswordType: &user.SetPassword_Password{
+ Password: &user.Password{
+ Password: "Password1!",
+ ChangeRequired: true,
+ },
+ },
+ },
+ },
+ },
+ want: &user.UpdateHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "change hashed password, code, ok",
+ prepare: func(request *user.UpdateHumanUserRequest) error {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ request.UserId = userID
+ resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{
+ UserId: userID,
+ Medium: &user.PasswordResetRequest_ReturnCode{
+ ReturnCode: &user.ReturnPasswordResetCode{},
+ },
+ })
+ if err != nil {
+ return err
+ }
+ request.Password.Verification = &user.SetPassword_VerificationCode{
+ VerificationCode: resp.GetVerificationCode(),
+ }
+ return nil
+ },
+ args: args{
+ CTX,
+ &user.UpdateHumanUserRequest{
+ Password: &user.SetPassword{
+ PasswordType: &user.SetPassword_HashedPassword{
+ HashedPassword: &user.HashedPassword{
+ Hash: "$2y$12$hXUrnqdq1RIIYZ2HPytIIe5lXdIvbhqrTvdPsSF7o.jFh817Z6lwm",
+ },
+ },
+ },
+ },
+ },
+ want: &user.UpdateHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "change hashed password, code, not supported",
+ prepare: func(request *user.UpdateHumanUserRequest) error {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ request.UserId = userID
+ resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{
+ UserId: userID,
+ Medium: &user.PasswordResetRequest_ReturnCode{
+ ReturnCode: &user.ReturnPasswordResetCode{},
+ },
+ })
+ if err != nil {
+ return err
+ }
+ request.Password = &user.SetPassword{
+ Verification: &user.SetPassword_VerificationCode{
+ VerificationCode: resp.GetVerificationCode(),
+ },
+ }
+ return nil
+ },
+ args: args{
+ CTX,
+ &user.UpdateHumanUserRequest{
+ Password: &user.SetPassword{
+ PasswordType: &user.SetPassword_HashedPassword{
+ HashedPassword: &user.HashedPassword{
+ Hash: "$scrypt$ln=16,r=8,p=1$cmFuZG9tc2FsdGlzaGFyZA$Rh+NnJNo1I6nRwaNqbDm6kmADswD1+7FTKZ7Ln9D8nQ",
+ },
+ },
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "change password, old password, ok",
+ prepare: func(request *user.UpdateHumanUserRequest) error {
+ userID := Tester.CreateHumanUser(CTX).GetUserId()
+ request.UserId = userID
+
+ resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{
+ UserId: userID,
+ Medium: &user.PasswordResetRequest_ReturnCode{
+ ReturnCode: &user.ReturnPasswordResetCode{},
+ },
+ })
+ if err != nil {
+ return err
+ }
+ pw := "Password1."
+ _, err = Client.SetPassword(CTX, &user.SetPasswordRequest{
+ UserId: userID,
+ NewPassword: &user.Password{
+ Password: pw,
+ ChangeRequired: true,
+ },
+ Verification: &user.SetPasswordRequest_VerificationCode{
+ VerificationCode: resp.GetVerificationCode(),
+ },
+ })
+ if err != nil {
+ return err
+ }
+ request.Password.Verification = &user.SetPassword_CurrentPassword{
+ CurrentPassword: pw,
+ }
+ return nil
+ },
+ args: args{
+ CTX,
+ &user.UpdateHumanUserRequest{
+ Password: &user.SetPassword{
+ PasswordType: &user.SetPassword_Password{
+ Password: &user.Password{
+ Password: "Password1!",
+ ChangeRequired: true,
+ },
+ },
+ },
+ },
+ },
+ want: &user.UpdateHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.prepare(tt.args.req)
+ require.NoError(t, err)
+
+ got, err := Client.UpdateHumanUser(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ if tt.want.GetEmailCode() != "" {
+ assert.NotEmpty(t, got.GetEmailCode())
+ }
+ if tt.want.GetPhoneCode() != "" {
+ assert.NotEmpty(t, got.GetPhoneCode())
+ }
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_UpdateHumanUser_Permission(t *testing.T) {
+ newOrgOwnerEmail := fmt.Sprintf("%d@permission.update.com", time.Now().UnixNano())
+ newOrg := Tester.CreateOrganization(IamCTX, fmt.Sprintf("UpdateHuman%d", time.Now().UnixNano()), newOrgOwnerEmail)
+ newUserID := newOrg.CreatedAdmins[0].GetUserId()
+ type args struct {
+ ctx context.Context
+ req *user.UpdateHumanUserRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.UpdateHumanUserResponse
+ wantErr bool
+ }{
+ {
+ name: "system, ok",
+ args: args{
+ SystemCTX,
+ &user.UpdateHumanUserRequest{
+ UserId: newUserID,
+ Username: gu.Ptr(fmt.Sprint("system", time.Now().UnixNano()+1)),
+ },
+ },
+ want: &user.UpdateHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: newOrg.GetOrganizationId(),
+ },
+ },
+ },
+ {
+ name: "instance, ok",
+ args: args{
+ IamCTX,
+ &user.UpdateHumanUserRequest{
+ UserId: newUserID,
+ Username: gu.Ptr(fmt.Sprint("instance", time.Now().UnixNano()+1)),
+ },
+ },
+ want: &user.UpdateHumanUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: newOrg.GetOrganizationId(),
+ },
+ },
+ },
+ {
+ name: "org, error",
+ args: args{
+ CTX,
+ &user.UpdateHumanUserRequest{
+ UserId: newUserID,
+ Username: gu.Ptr(fmt.Sprint("org", time.Now().UnixNano()+1)),
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "user, error",
+ args: args{
+ UserCTX,
+ &user.UpdateHumanUserRequest{
+ UserId: newUserID,
+ Username: gu.Ptr(fmt.Sprint("user", time.Now().UnixNano()+1)),
+ },
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+
+ got, err := Client.UpdateHumanUser(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_LockUser(t *testing.T) {
+ type args struct {
+ ctx context.Context
+ req *user.LockUserRequest
+ prepare func(request *user.LockUserRequest) error
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.LockUserResponse
+ wantErr bool
+ }{
+ {
+ name: "lock, not existing",
+ args: args{
+ CTX,
+ &user.LockUserRequest{
+ UserId: "notexisting",
+ },
+ func(request *user.LockUserRequest) error { return nil },
+ },
+ wantErr: true,
+ },
+ {
+ name: "lock, ok",
+ args: args{
+ CTX,
+ &user.LockUserRequest{},
+ func(request *user.LockUserRequest) error {
+ resp := Tester.CreateHumanUser(CTX)
+ request.UserId = resp.GetUserId()
+ return nil
+ },
+ },
+ want: &user.LockUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "lock machine, ok",
+ args: args{
+ CTX,
+ &user.LockUserRequest{},
+ func(request *user.LockUserRequest) error {
+ resp := Tester.CreateMachineUser(CTX)
+ request.UserId = resp.GetUserId()
+ return nil
+ },
+ },
+ want: &user.LockUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "lock, already locked",
+ args: args{
+ CTX,
+ &user.LockUserRequest{},
+ func(request *user.LockUserRequest) error {
+ resp := Tester.CreateHumanUser(CTX)
+ request.UserId = resp.GetUserId()
+ _, err := Client.LockUser(CTX, &user.LockUserRequest{
+ UserId: resp.GetUserId(),
+ })
+ return err
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "lock machine, already locked",
+ args: args{
+ CTX,
+ &user.LockUserRequest{},
+ func(request *user.LockUserRequest) error {
+ resp := Tester.CreateMachineUser(CTX)
+ request.UserId = resp.GetUserId()
+ _, err := Client.LockUser(CTX, &user.LockUserRequest{
+ UserId: resp.GetUserId(),
+ })
+ return err
+ },
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.args.prepare(tt.args.req)
+ require.NoError(t, err)
+
+ got, err := Client.LockUser(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_UnLockUser(t *testing.T) {
+ type args struct {
+ ctx context.Context
+ req *user.UnlockUserRequest
+ prepare func(request *user.UnlockUserRequest) error
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.UnlockUserResponse
+ wantErr bool
+ }{
+ {
+ name: "unlock, not existing",
+ args: args{
+ CTX,
+ &user.UnlockUserRequest{
+ UserId: "notexisting",
+ },
+ func(request *user.UnlockUserRequest) error { return nil },
+ },
+ wantErr: true,
+ },
+ {
+ name: "unlock, not locked",
+ args: args{
+ ctx: CTX,
+ req: &user.UnlockUserRequest{},
+ prepare: func(request *user.UnlockUserRequest) error {
+ resp := Tester.CreateHumanUser(CTX)
+ request.UserId = resp.GetUserId()
+ return nil
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "unlock machine, not locked",
+ args: args{
+ ctx: CTX,
+ req: &user.UnlockUserRequest{},
+ prepare: func(request *user.UnlockUserRequest) error {
+ resp := Tester.CreateMachineUser(CTX)
+ request.UserId = resp.GetUserId()
+ return nil
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "unlock, ok",
+ args: args{
+ ctx: CTX,
+ req: &user.UnlockUserRequest{},
+ prepare: func(request *user.UnlockUserRequest) error {
+ resp := Tester.CreateHumanUser(CTX)
+ request.UserId = resp.GetUserId()
+ _, err := Client.LockUser(CTX, &user.LockUserRequest{
+ UserId: resp.GetUserId(),
+ })
+ return err
+ },
+ },
+ want: &user.UnlockUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "unlock machine, ok",
+ args: args{
+ ctx: CTX,
+ req: &user.UnlockUserRequest{},
+ prepare: func(request *user.UnlockUserRequest) error {
+ resp := Tester.CreateMachineUser(CTX)
+ request.UserId = resp.GetUserId()
+ _, err := Client.LockUser(CTX, &user.LockUserRequest{
+ UserId: resp.GetUserId(),
+ })
+ return err
+ },
+ },
+ want: &user.UnlockUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.args.prepare(tt.args.req)
+ require.NoError(t, err)
+
+ got, err := Client.UnlockUser(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_DeactivateUser(t *testing.T) {
+ type args struct {
+ ctx context.Context
+ req *user.DeactivateUserRequest
+ prepare func(request *user.DeactivateUserRequest) error
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.DeactivateUserResponse
+ wantErr bool
+ }{
+ {
+ name: "deactivate, not existing",
+ args: args{
+ CTX,
+ &user.DeactivateUserRequest{
+ UserId: "notexisting",
+ },
+ func(request *user.DeactivateUserRequest) error { return nil },
+ },
+ wantErr: true,
+ },
+ {
+ name: "deactivate, ok",
+ args: args{
+ CTX,
+ &user.DeactivateUserRequest{},
+ func(request *user.DeactivateUserRequest) error {
+ resp := Tester.CreateHumanUser(CTX)
+ request.UserId = resp.GetUserId()
+ return nil
+ },
+ },
+ want: &user.DeactivateUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "deactivate machine, ok",
+ args: args{
+ CTX,
+ &user.DeactivateUserRequest{},
+ func(request *user.DeactivateUserRequest) error {
+ resp := Tester.CreateMachineUser(CTX)
+ request.UserId = resp.GetUserId()
+ return nil
+ },
+ },
+ want: &user.DeactivateUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "deactivate, already deactivated",
+ args: args{
+ CTX,
+ &user.DeactivateUserRequest{},
+ func(request *user.DeactivateUserRequest) error {
+ resp := Tester.CreateHumanUser(CTX)
+ request.UserId = resp.GetUserId()
+ _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{
+ UserId: resp.GetUserId(),
+ })
+ return err
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "deactivate machine, already deactivated",
+ args: args{
+ CTX,
+ &user.DeactivateUserRequest{},
+ func(request *user.DeactivateUserRequest) error {
+ resp := Tester.CreateMachineUser(CTX)
+ request.UserId = resp.GetUserId()
+ _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{
+ UserId: resp.GetUserId(),
+ })
+ return err
+ },
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.args.prepare(tt.args.req)
+ require.NoError(t, err)
+
+ got, err := Client.DeactivateUser(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_ReactivateUser(t *testing.T) {
+ type args struct {
+ ctx context.Context
+ req *user.ReactivateUserRequest
+ prepare func(request *user.ReactivateUserRequest) error
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.ReactivateUserResponse
+ wantErr bool
+ }{
+ {
+ name: "reactivate, not existing",
+ args: args{
+ CTX,
+ &user.ReactivateUserRequest{
+ UserId: "notexisting",
+ },
+ func(request *user.ReactivateUserRequest) error { return nil },
+ },
+ wantErr: true,
+ },
+ {
+ name: "reactivate, not deactivated",
+ args: args{
+ ctx: CTX,
+ req: &user.ReactivateUserRequest{},
+ prepare: func(request *user.ReactivateUserRequest) error {
+ resp := Tester.CreateHumanUser(CTX)
+ request.UserId = resp.GetUserId()
+ return nil
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "reactivate machine, not deactivated",
+ args: args{
+ ctx: CTX,
+ req: &user.ReactivateUserRequest{},
+ prepare: func(request *user.ReactivateUserRequest) error {
+ resp := Tester.CreateMachineUser(CTX)
+ request.UserId = resp.GetUserId()
+ return nil
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "reactivate, ok",
+ args: args{
+ ctx: CTX,
+ req: &user.ReactivateUserRequest{},
+ prepare: func(request *user.ReactivateUserRequest) error {
+ resp := Tester.CreateHumanUser(CTX)
+ request.UserId = resp.GetUserId()
+ _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{
+ UserId: resp.GetUserId(),
+ })
+ return err
+ },
+ },
+ want: &user.ReactivateUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "reactivate machine, ok",
+ args: args{
+ ctx: CTX,
+ req: &user.ReactivateUserRequest{},
+ prepare: func(request *user.ReactivateUserRequest) error {
+ resp := Tester.CreateMachineUser(CTX)
+ request.UserId = resp.GetUserId()
+ _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{
+ UserId: resp.GetUserId(),
+ })
+ return err
+ },
+ },
+ want: &user.ReactivateUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.args.prepare(tt.args.req)
+ require.NoError(t, err)
+
+ got, err := Client.ReactivateUser(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_DeleteUser(t *testing.T) {
+ projectResp, err := Tester.CreateProject(CTX)
+ require.NoError(t, err)
+ type args struct {
+ ctx context.Context
+ req *user.DeleteUserRequest
+ prepare func(request *user.DeleteUserRequest) error
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.DeleteUserResponse
+ wantErr bool
+ }{
+ {
+ name: "remove, not existing",
+ args: args{
+ CTX,
+ &user.DeleteUserRequest{
+ UserId: "notexisting",
+ },
+ func(request *user.DeleteUserRequest) error { return nil },
+ },
+ wantErr: true,
+ },
+ {
+ name: "remove human, ok",
+ args: args{
+ ctx: CTX,
+ req: &user.DeleteUserRequest{},
+ prepare: func(request *user.DeleteUserRequest) error {
+ resp := Tester.CreateHumanUser(CTX)
+ request.UserId = resp.GetUserId()
+ return err
+ },
+ },
+ want: &user.DeleteUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "remove machine, ok",
+ args: args{
+ ctx: CTX,
+ req: &user.DeleteUserRequest{},
+ prepare: func(request *user.DeleteUserRequest) error {
+ resp := Tester.CreateMachineUser(CTX)
+ request.UserId = resp.GetUserId()
+ return err
+ },
+ },
+ want: &user.DeleteUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ {
+ name: "remove dependencies, ok",
+ args: args{
+ ctx: CTX,
+ req: &user.DeleteUserRequest{},
+ prepare: func(request *user.DeleteUserRequest) error {
+ resp := Tester.CreateHumanUser(CTX)
+ request.UserId = resp.GetUserId()
+ Tester.CreateProjectUserGrant(t, CTX, projectResp.GetId(), request.UserId)
+ Tester.CreateProjectMembership(t, CTX, projectResp.GetId(), request.UserId)
+ Tester.CreateOrgMembership(t, CTX, request.UserId)
+ return err
+ },
+ },
+ want: &user.DeleteUserResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.args.prepare(tt.args.req)
+ require.NoError(t, err)
+
+ got, err := Client.DeleteUser(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_AddIDPLink(t *testing.T) {
+ idpID := Tester.AddGenericOAuthProvider(t, CTX)
+ type args struct {
+ ctx context.Context
+ req *user.AddIDPLinkRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.AddIDPLinkResponse
+ wantErr bool
+ }{
+ {
+ name: "user does not exist",
+ args: args{
+ CTX,
+ &user.AddIDPLinkRequest{
+ UserId: "userID",
+ IdpLink: &user.IDPLink{
+ IdpId: idpID,
+ UserId: "userID",
+ UserName: "username",
+ },
+ },
+ },
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "idp does not exist",
+ args: args{
+ CTX,
+ &user.AddIDPLinkRequest{
+ UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID,
+ IdpLink: &user.IDPLink{
+ IdpId: "idpID",
+ UserId: "userID",
+ UserName: "username",
+ },
+ },
+ },
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "add link",
+ args: args{
+ CTX,
+ &user.AddIDPLinkRequest{
+ UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID,
+ IdpLink: &user.IDPLink{
+ IdpId: idpID,
+ UserId: "userID",
+ UserName: "username",
+ },
+ },
+ },
+ want: &user.AddIDPLinkResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Organisation.ID,
+ },
+ },
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.AddIDPLink(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+
+ integration.AssertDetails(t, tt.want, got)
+ })
+ }
+}
+
+func TestServer_StartIdentityProviderIntent(t *testing.T) {
+ idpID := Tester.AddGenericOAuthProvider(t, CTX)
+ orgIdpID := Tester.AddOrgGenericOAuthProvider(t, CTX, Tester.Organisation.ID)
+ orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("NotDefaultOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()))
+ notDefaultOrgIdpID := Tester.AddOrgGenericOAuthProvider(t, CTX, orgResp.OrganizationId)
+ samlIdpID := Tester.AddSAMLProvider(t, CTX)
+ samlRedirectIdpID := Tester.AddSAMLRedirectProvider(t, CTX, "")
+ samlPostIdpID := Tester.AddSAMLPostProvider(t, CTX)
+ type args struct {
+ ctx context.Context
+ req *user.StartIdentityProviderIntentRequest
+ }
+ type want struct {
+ details *object.Details
+ url string
+ parametersExisting []string
+ parametersEqual map[string]string
+ postForm bool
+ }
+ tests := []struct {
+ name string
+ args args
+ want want
+ wantErr bool
+ }{
+ {
+ name: "missing urls",
+ args: args{
+ CTX,
+ &user.StartIdentityProviderIntentRequest{
+ IdpId: idpID,
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "next step oauth auth url",
+ args: args{
+ CTX,
+ &user.StartIdentityProviderIntentRequest{
+ IdpId: idpID,
+ Content: &user.StartIdentityProviderIntentRequest_Urls{
+ Urls: &user.RedirectURLs{
+ SuccessUrl: "https://example.com/success",
+ FailureUrl: "https://example.com/failure",
+ },
+ },
+ },
+ },
+ want: want{
+ details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ url: "https://example.com/oauth/v2/authorize",
+ parametersEqual: map[string]string{
+ "client_id": "clientID",
+ "prompt": "select_account",
+ "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback",
+ "response_type": "code",
+ "scope": "openid profile email",
+ },
+ parametersExisting: []string{"state"},
+ },
+ wantErr: false,
+ },
+ {
+ name: "next step oauth auth url, default org",
+ args: args{
+ CTX,
+ &user.StartIdentityProviderIntentRequest{
+ IdpId: orgIdpID,
+ Content: &user.StartIdentityProviderIntentRequest_Urls{
+ Urls: &user.RedirectURLs{
+ SuccessUrl: "https://example.com/success",
+ FailureUrl: "https://example.com/failure",
+ },
+ },
+ },
+ },
+ want: want{
+ details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ url: "https://example.com/oauth/v2/authorize",
+ parametersEqual: map[string]string{
+ "client_id": "clientID",
+ "prompt": "select_account",
+ "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback",
+ "response_type": "code",
+ "scope": "openid profile email",
+ },
+ parametersExisting: []string{"state"},
+ },
+ wantErr: false,
+ },
+ {
+ name: "next step oauth auth url, default org",
+ args: args{
+ CTX,
+ &user.StartIdentityProviderIntentRequest{
+ IdpId: notDefaultOrgIdpID,
+ Content: &user.StartIdentityProviderIntentRequest_Urls{
+ Urls: &user.RedirectURLs{
+ SuccessUrl: "https://example.com/success",
+ FailureUrl: "https://example.com/failure",
+ },
+ },
+ },
+ },
+ want: want{
+ details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ url: "https://example.com/oauth/v2/authorize",
+ parametersEqual: map[string]string{
+ "client_id": "clientID",
+ "prompt": "select_account",
+ "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback",
+ "response_type": "code",
+ "scope": "openid profile email",
+ },
+ parametersExisting: []string{"state"},
+ },
+ wantErr: false,
+ },
+ {
+ name: "next step oauth auth url org",
+ args: args{
+ CTX,
+ &user.StartIdentityProviderIntentRequest{
+ IdpId: orgIdpID,
+ Content: &user.StartIdentityProviderIntentRequest_Urls{
+ Urls: &user.RedirectURLs{
+ SuccessUrl: "https://example.com/success",
+ FailureUrl: "https://example.com/failure",
+ },
+ },
+ },
+ },
+ want: want{
+ details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ url: "https://example.com/oauth/v2/authorize",
+ parametersEqual: map[string]string{
+ "client_id": "clientID",
+ "prompt": "select_account",
+ "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback",
+ "response_type": "code",
+ "scope": "openid profile email",
+ },
+ parametersExisting: []string{"state"},
+ },
+ wantErr: false,
+ },
+ {
+ name: "next step saml default",
+ args: args{
+ CTX,
+ &user.StartIdentityProviderIntentRequest{
+ IdpId: samlIdpID,
+ Content: &user.StartIdentityProviderIntentRequest_Urls{
+ Urls: &user.RedirectURLs{
+ SuccessUrl: "https://example.com/success",
+ FailureUrl: "https://example.com/failure",
+ },
+ },
+ },
+ },
+ want: want{
+ details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ url: "http://" + Tester.Config.ExternalDomain + ":8000/sso",
+ parametersExisting: []string{"RelayState", "SAMLRequest"},
+ },
+ wantErr: false,
+ },
+ {
+ name: "next step saml auth url",
+ args: args{
+ CTX,
+ &user.StartIdentityProviderIntentRequest{
+ IdpId: samlRedirectIdpID,
+ Content: &user.StartIdentityProviderIntentRequest_Urls{
+ Urls: &user.RedirectURLs{
+ SuccessUrl: "https://example.com/success",
+ FailureUrl: "https://example.com/failure",
+ },
+ },
+ },
+ },
+ want: want{
+ details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ url: "http://" + Tester.Config.ExternalDomain + ":8000/sso",
+ parametersExisting: []string{"RelayState", "SAMLRequest"},
+ },
+ wantErr: false,
+ },
+ {
+ name: "next step saml form",
+ args: args{
+ CTX,
+ &user.StartIdentityProviderIntentRequest{
+ IdpId: samlPostIdpID,
+ Content: &user.StartIdentityProviderIntentRequest_Urls{
+ Urls: &user.RedirectURLs{
+ SuccessUrl: "https://example.com/success",
+ FailureUrl: "https://example.com/failure",
+ },
+ },
+ },
+ },
+ want: want{
+ details: &object.Details{
+ ChangeDate: timestamppb.Now(),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ },
+ postForm: true,
+ },
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.StartIdentityProviderIntent(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+
+ if tt.want.url != "" {
+ authUrl, err := url.Parse(got.GetAuthUrl())
+ assert.NoError(t, err)
+
+ assert.Len(t, authUrl.Query(), len(tt.want.parametersEqual)+len(tt.want.parametersExisting))
+
+ for _, existing := range tt.want.parametersExisting {
+ assert.True(t, authUrl.Query().Has(existing))
+ }
+ for key, equal := range tt.want.parametersEqual {
+ assert.Equal(t, equal, authUrl.Query().Get(key))
+ }
+ }
+ if tt.want.postForm {
+ assert.NotEmpty(t, got.GetPostForm())
+ }
+ integration.AssertDetails(t, &user.StartIdentityProviderIntentResponse{
+ Details: tt.want.details,
+ }, got)
+ })
+ }
+}
+
+func TestServer_RetrieveIdentityProviderIntent(t *testing.T) {
+ idpID := Tester.AddGenericOAuthProvider(t, CTX)
+ intentID := Tester.CreateIntent(t, CTX, idpID)
+ successfulID, token, changeDate, sequence := Tester.CreateSuccessfulOAuthIntent(t, CTX, idpID, "", "id")
+ successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence := Tester.CreateSuccessfulOAuthIntent(t, CTX, idpID, "user", "id")
+ ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence := Tester.CreateSuccessfulLDAPIntent(t, CTX, idpID, "", "id")
+ ldapSuccessfulWithUserID, ldapWithUserToken, ldapWithUserChangeDate, ldapWithUserSequence := Tester.CreateSuccessfulLDAPIntent(t, CTX, idpID, "user", "id")
+ samlSuccessfulID, samlToken, samlChangeDate, samlSequence := Tester.CreateSuccessfulSAMLIntent(t, CTX, idpID, "", "id")
+ type args struct {
+ ctx context.Context
+ req *user.RetrieveIdentityProviderIntentRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.RetrieveIdentityProviderIntentResponse
+ wantErr bool
+ }{
+ {
+ name: "failed intent",
+ args: args{
+ CTX,
+ &user.RetrieveIdentityProviderIntentRequest{
+ IdpIntentId: intentID,
+ IdpIntentToken: "",
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "wrong token",
+ args: args{
+ CTX,
+ &user.RetrieveIdentityProviderIntentRequest{
+ IdpIntentId: successfulID,
+ IdpIntentToken: "wrong token",
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "retrieve successful intent",
+ args: args{
+ CTX,
+ &user.RetrieveIdentityProviderIntentRequest{
+ IdpIntentId: successfulID,
+ IdpIntentToken: token,
+ },
+ },
+ want: &user.RetrieveIdentityProviderIntentResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.New(changeDate),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ Sequence: sequence,
+ },
+ IdpInformation: &user.IDPInformation{
+ Access: &user.IDPInformation_Oauth{
+ Oauth: &user.IDPOAuthAccessInformation{
+ AccessToken: "accessToken",
+ IdToken: gu.Ptr("idToken"),
+ },
+ },
+ IdpId: idpID,
+ UserId: "id",
+ UserName: "username",
+ RawInformation: func() *structpb.Struct {
+ s, err := structpb.NewStruct(map[string]interface{}{
+ "sub": "id",
+ "preferred_username": "username",
+ })
+ require.NoError(t, err)
+ return s
+ }(),
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "retrieve successful intent with linked user",
+ args: args{
+ CTX,
+ &user.RetrieveIdentityProviderIntentRequest{
+ IdpIntentId: successfulWithUserID,
+ IdpIntentToken: withUsertoken,
+ },
+ },
+ want: &user.RetrieveIdentityProviderIntentResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.New(withUserchangeDate),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ Sequence: withUsersequence,
+ },
+ UserId: "user",
+ IdpInformation: &user.IDPInformation{
+ Access: &user.IDPInformation_Oauth{
+ Oauth: &user.IDPOAuthAccessInformation{
+ AccessToken: "accessToken",
+ IdToken: gu.Ptr("idToken"),
+ },
+ },
+ IdpId: idpID,
+ UserId: "id",
+ UserName: "username",
+ RawInformation: func() *structpb.Struct {
+ s, err := structpb.NewStruct(map[string]interface{}{
+ "sub": "id",
+ "preferred_username": "username",
+ })
+ require.NoError(t, err)
+ return s
+ }(),
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "retrieve successful ldap intent",
+ args: args{
+ CTX,
+ &user.RetrieveIdentityProviderIntentRequest{
+ IdpIntentId: ldapSuccessfulID,
+ IdpIntentToken: ldapToken,
+ },
+ },
+ want: &user.RetrieveIdentityProviderIntentResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.New(ldapChangeDate),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ Sequence: ldapSequence,
+ },
+ IdpInformation: &user.IDPInformation{
+ Access: &user.IDPInformation_Ldap{
+ Ldap: &user.IDPLDAPAccessInformation{
+ Attributes: func() *structpb.Struct {
+ s, err := structpb.NewStruct(map[string]interface{}{
+ "id": []interface{}{"id"},
+ "username": []interface{}{"username"},
+ "language": []interface{}{"en"},
+ })
+ require.NoError(t, err)
+ return s
+ }(),
+ },
+ },
+ IdpId: idpID,
+ UserId: "id",
+ UserName: "username",
+ RawInformation: func() *structpb.Struct {
+ s, err := structpb.NewStruct(map[string]interface{}{
+ "id": "id",
+ "preferredUsername": "username",
+ "preferredLanguage": "en",
+ })
+ require.NoError(t, err)
+ return s
+ }(),
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "retrieve successful ldap intent with linked user",
+ args: args{
+ CTX,
+ &user.RetrieveIdentityProviderIntentRequest{
+ IdpIntentId: ldapSuccessfulWithUserID,
+ IdpIntentToken: ldapWithUserToken,
+ },
+ },
+ want: &user.RetrieveIdentityProviderIntentResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.New(ldapWithUserChangeDate),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ Sequence: ldapWithUserSequence,
+ },
+ UserId: "user",
+ IdpInformation: &user.IDPInformation{
+ Access: &user.IDPInformation_Ldap{
+ Ldap: &user.IDPLDAPAccessInformation{
+ Attributes: func() *structpb.Struct {
+ s, err := structpb.NewStruct(map[string]interface{}{
+ "id": []interface{}{"id"},
+ "username": []interface{}{"username"},
+ "language": []interface{}{"en"},
+ })
+ require.NoError(t, err)
+ return s
+ }(),
+ },
+ },
+ IdpId: idpID,
+ UserId: "id",
+ UserName: "username",
+ RawInformation: func() *structpb.Struct {
+ s, err := structpb.NewStruct(map[string]interface{}{
+ "id": "id",
+ "preferredUsername": "username",
+ "preferredLanguage": "en",
+ })
+ require.NoError(t, err)
+ return s
+ }(),
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "retrieve successful saml intent",
+ args: args{
+ CTX,
+ &user.RetrieveIdentityProviderIntentRequest{
+ IdpIntentId: samlSuccessfulID,
+ IdpIntentToken: samlToken,
+ },
+ },
+ want: &user.RetrieveIdentityProviderIntentResponse{
+ Details: &object.Details{
+ ChangeDate: timestamppb.New(samlChangeDate),
+ ResourceOwner: Tester.Instance.InstanceID(),
+ Sequence: samlSequence,
+ },
+ IdpInformation: &user.IDPInformation{
+ Access: &user.IDPInformation_Saml{
+ Saml: &user.IDPSAMLAccessInformation{
+ Assertion: []byte(""),
+ },
+ },
+ IdpId: idpID,
+ UserId: "id",
+ UserName: "",
+ RawInformation: func() *structpb.Struct {
+ s, err := structpb.NewStruct(map[string]interface{}{
+ "id": "id",
+ "attributes": map[string]interface{}{
+ "attribute1": []interface{}{"value1"},
+ },
+ })
+ require.NoError(t, err)
+ return s
+ }(),
+ },
+ },
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := Client.RetrieveIdentityProviderIntent(tt.args.ctx, tt.args.req)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+
+ grpc.AllFieldsEqual(t, tt.want.ProtoReflect(), got.ProtoReflect(), grpc.CustomMappers)
+ })
+ }
+}
+
+func TestServer_ListAuthenticationMethodTypes(t *testing.T) {
+ userIDWithoutAuth := Tester.CreateHumanUser(CTX).GetUserId()
+
+ userIDWithPasskey := Tester.CreateHumanUser(CTX).GetUserId()
+ Tester.RegisterUserPasskey(CTX, userIDWithPasskey)
+
+ userMultipleAuth := Tester.CreateHumanUser(CTX).GetUserId()
+ Tester.RegisterUserPasskey(CTX, userMultipleAuth)
+ provider, err := Tester.Client.Mgmt.AddGenericOIDCProvider(CTX, &mgmt.AddGenericOIDCProviderRequest{
+ Name: "ListAuthenticationMethodTypes",
+ Issuer: "https://example.com",
+ ClientId: "client_id",
+ ClientSecret: "client_secret",
+ })
+ require.NoError(t, err)
+ _, err = Tester.Client.Mgmt.AddCustomLoginPolicy(CTX, &mgmt.AddCustomLoginPolicyRequest{})
+ require.Condition(t, func() bool {
+ code := status.Convert(err).Code()
+ return code == codes.AlreadyExists || code == codes.OK
+ })
+ _, err = Tester.Client.Mgmt.AddIDPToLoginPolicy(CTX, &mgmt.AddIDPToLoginPolicyRequest{
+ IdpId: provider.GetId(),
+ OwnerType: idp.IDPOwnerType_IDP_OWNER_TYPE_ORG,
+ })
+ require.NoError(t, err)
+ idpLink, err := Client.AddIDPLink(CTX, &user.AddIDPLinkRequest{UserId: userMultipleAuth, IdpLink: &user.IDPLink{
+ IdpId: provider.GetId(),
+ UserId: "external-id",
+ UserName: "displayName",
+ }})
+ require.NoError(t, err)
+ // This should not remove the user IDP links
+ _, err = Tester.Client.Mgmt.RemoveIDPFromLoginPolicy(CTX, &mgmt.RemoveIDPFromLoginPolicyRequest{
+ IdpId: provider.GetId(),
+ })
+ require.NoError(t, err)
+
+ type args struct {
+ ctx context.Context
+ req *user.ListAuthenticationMethodTypesRequest
+ }
+ tests := []struct {
+ name string
+ args args
+ want *user.ListAuthenticationMethodTypesResponse
+ }{
+ {
+ name: "no auth",
+ args: args{
+ CTX,
+ &user.ListAuthenticationMethodTypesRequest{
+ UserId: userIDWithoutAuth,
+ },
+ },
+ want: &user.ListAuthenticationMethodTypesResponse{
+ Details: &object.ListDetails{
+ TotalResult: 0,
+ },
+ },
+ },
+ {
+ name: "with auth (passkey)",
+ args: args{
+ CTX,
+ &user.ListAuthenticationMethodTypesRequest{
+ UserId: userIDWithPasskey,
+ },
+ },
+ want: &user.ListAuthenticationMethodTypesResponse{
+ Details: &object.ListDetails{
+ TotalResult: 1,
+ },
+ AuthMethodTypes: []user.AuthenticationMethodType{
+ user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY,
+ },
+ },
+ },
+ {
+ name: "multiple auth",
+ args: args{
+ CTX,
+ &user.ListAuthenticationMethodTypesRequest{
+ UserId: userMultipleAuth,
+ },
+ },
+ want: &user.ListAuthenticationMethodTypesResponse{
+ Details: &object.ListDetails{
+ TotalResult: 2,
+ },
+ AuthMethodTypes: []user.AuthenticationMethodType{
+ user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY,
+ user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var got *user.ListAuthenticationMethodTypesResponse
+ var err error
+
+ for {
+ got, err = Client.ListAuthenticationMethodTypes(tt.args.ctx, tt.args.req)
+ if err == nil && !got.GetDetails().GetTimestamp().AsTime().Before(idpLink.GetDetails().GetChangeDate().AsTime()) {
+ break
+ }
+ select {
+ case <-CTX.Done():
+ t.Fatal(CTX.Err(), err)
+ case <-time.After(time.Second):
+ t.Log("retrying ListAuthenticationMethodTypes")
+ continue
+ }
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tt.want.GetDetails().GetTotalResult(), got.GetDetails().GetTotalResult())
+ require.Equal(t, tt.want.GetAuthMethodTypes(), got.GetAuthMethodTypes())
+ })
+ }
+}
diff --git a/internal/api/grpc/user/v2beta/user_test.go b/internal/api/grpc/user/v2beta/user_test.go
new file mode 100644
index 0000000000..9e398e83ff
--- /dev/null
+++ b/internal/api/grpc/user/v2beta/user_test.go
@@ -0,0 +1,410 @@
+package user
+
+import (
+ "testing"
+ "time"
+
+ "github.com/muhlemmer/gu"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+ "google.golang.org/protobuf/types/known/structpb"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/zitadel/zitadel/internal/api/grpc"
+ "github.com/zitadel/zitadel/internal/command"
+ "github.com/zitadel/zitadel/internal/crypto"
+ "github.com/zitadel/zitadel/internal/domain"
+ "github.com/zitadel/zitadel/internal/eventstore"
+ "github.com/zitadel/zitadel/internal/zerrors"
+ object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
+ user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+)
+
+func Test_idpIntentToIDPIntentPb(t *testing.T) {
+ decryption := func(err error) crypto.EncryptionAlgorithm {
+ mCrypto := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t))
+ mCrypto.EXPECT().Algorithm().Return("enc")
+ mCrypto.EXPECT().DecryptionKeyIDs().Return([]string{"id"})
+ mCrypto.EXPECT().DecryptString(gomock.Any(), gomock.Any()).DoAndReturn(
+ func(code []byte, keyID string) (string, error) {
+ if err != nil {
+ return "", err
+ }
+ return string(code), nil
+ })
+ return mCrypto
+ }
+
+ type args struct {
+ intent *command.IDPIntentWriteModel
+ alg crypto.EncryptionAlgorithm
+ }
+ type res struct {
+ resp *user.RetrieveIdentityProviderIntentResponse
+ err error
+ }
+ tests := []struct {
+ name string
+ args args
+ res res
+ }{
+ {
+ "decryption invalid key id error",
+ args{
+ intent: &command.IDPIntentWriteModel{
+ WriteModel: eventstore.WriteModel{
+ AggregateID: "intentID",
+ ProcessedSequence: 123,
+ ResourceOwner: "ro",
+ InstanceID: "instanceID",
+ ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local),
+ },
+ IDPID: "idpID",
+ IDPUser: []byte(`{"userID": "idpUserID", "username": "username"}`),
+ IDPUserID: "idpUserID",
+ IDPUserName: "username",
+ IDPAccessToken: &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("accessToken"),
+ },
+ IDPIDToken: "idToken",
+ IDPEntryAttributes: map[string][]string{},
+ UserID: "userID",
+ State: domain.IDPIntentStateSucceeded,
+ },
+ alg: decryption(zerrors.ThrowInternal(nil, "id", "invalid key id")),
+ },
+ res{
+ resp: nil,
+ err: zerrors.ThrowInternal(nil, "id", "invalid key id"),
+ },
+ }, {
+ "successful oauth",
+ args{
+ intent: &command.IDPIntentWriteModel{
+ WriteModel: eventstore.WriteModel{
+ AggregateID: "intentID",
+ ProcessedSequence: 123,
+ ResourceOwner: "ro",
+ InstanceID: "instanceID",
+ ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local),
+ },
+ IDPID: "idpID",
+ IDPUser: []byte(`{"userID": "idpUserID", "username": "username"}`),
+ IDPUserID: "idpUserID",
+ IDPUserName: "username",
+ IDPAccessToken: &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("accessToken"),
+ },
+ IDPIDToken: "idToken",
+ UserID: "",
+ State: domain.IDPIntentStateSucceeded,
+ },
+ alg: decryption(nil),
+ },
+ res{
+ resp: &user.RetrieveIdentityProviderIntentResponse{
+ Details: &object_pb.Details{
+ Sequence: 123,
+ ChangeDate: timestamppb.New(time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local)),
+ ResourceOwner: "ro",
+ },
+ IdpInformation: &user.IDPInformation{
+ Access: &user.IDPInformation_Oauth{
+ Oauth: &user.IDPOAuthAccessInformation{
+ AccessToken: "accessToken",
+ IdToken: gu.Ptr("idToken"),
+ },
+ },
+ IdpId: "idpID",
+ UserId: "idpUserID",
+ UserName: "username",
+ RawInformation: func() *structpb.Struct {
+ s, err := structpb.NewStruct(map[string]interface{}{
+ "userID": "idpUserID",
+ "username": "username",
+ })
+ require.NoError(t, err)
+ return s
+ }(),
+ },
+ },
+ err: nil,
+ },
+ },
+ {
+ "successful oauth with linked user",
+ args{
+ intent: &command.IDPIntentWriteModel{
+ WriteModel: eventstore.WriteModel{
+ AggregateID: "intentID",
+ ProcessedSequence: 123,
+ ResourceOwner: "ro",
+ InstanceID: "instanceID",
+ ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local),
+ },
+ IDPID: "idpID",
+ IDPUser: []byte(`{"userID": "idpUserID", "username": "username"}`),
+ IDPUserID: "idpUserID",
+ IDPUserName: "username",
+ IDPAccessToken: &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("accessToken"),
+ },
+ IDPIDToken: "idToken",
+ UserID: "userID",
+ State: domain.IDPIntentStateSucceeded,
+ },
+ alg: decryption(nil),
+ },
+ res{
+ resp: &user.RetrieveIdentityProviderIntentResponse{
+ Details: &object_pb.Details{
+ Sequence: 123,
+ ChangeDate: timestamppb.New(time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local)),
+ ResourceOwner: "ro",
+ },
+ IdpInformation: &user.IDPInformation{
+ Access: &user.IDPInformation_Oauth{
+ Oauth: &user.IDPOAuthAccessInformation{
+ AccessToken: "accessToken",
+ IdToken: gu.Ptr("idToken"),
+ },
+ },
+ IdpId: "idpID",
+ UserId: "idpUserID",
+ UserName: "username",
+ RawInformation: func() *structpb.Struct {
+ s, err := structpb.NewStruct(map[string]interface{}{
+ "userID": "idpUserID",
+ "username": "username",
+ })
+ require.NoError(t, err)
+ return s
+ }(),
+ },
+ UserId: "userID",
+ },
+ err: nil,
+ },
+ }, {
+ "successful ldap",
+ args{
+ intent: &command.IDPIntentWriteModel{
+ WriteModel: eventstore.WriteModel{
+ AggregateID: "intentID",
+ ProcessedSequence: 123,
+ ResourceOwner: "ro",
+ InstanceID: "instanceID",
+ ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local),
+ },
+ IDPID: "idpID",
+ IDPUser: []byte(`{"userID": "idpUserID", "username": "username"}`),
+ IDPUserID: "idpUserID",
+ IDPUserName: "username",
+ IDPEntryAttributes: map[string][]string{
+ "id": {"idpUserID"},
+ "firstName": {"firstname1", "firstname2"},
+ "lastName": {"lastname"},
+ },
+ UserID: "",
+ State: domain.IDPIntentStateSucceeded,
+ },
+ },
+ res{
+ resp: &user.RetrieveIdentityProviderIntentResponse{
+ Details: &object_pb.Details{
+ Sequence: 123,
+ ChangeDate: timestamppb.New(time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local)),
+ ResourceOwner: "ro",
+ },
+ IdpInformation: &user.IDPInformation{
+ Access: &user.IDPInformation_Ldap{
+ Ldap: &user.IDPLDAPAccessInformation{
+ Attributes: func() *structpb.Struct {
+ s, err := structpb.NewStruct(map[string]interface{}{
+ "id": []interface{}{"idpUserID"},
+ "firstName": []interface{}{"firstname1", "firstname2"},
+ "lastName": []interface{}{"lastname"},
+ })
+ require.NoError(t, err)
+ return s
+ }(),
+ },
+ },
+ IdpId: "idpID",
+ UserId: "idpUserID",
+ UserName: "username",
+ RawInformation: func() *structpb.Struct {
+ s, err := structpb.NewStruct(map[string]interface{}{
+ "userID": "idpUserID",
+ "username": "username",
+ })
+ require.NoError(t, err)
+ return s
+ }(),
+ },
+ },
+ err: nil,
+ },
+ }, {
+ "successful ldap with linked user",
+ args{
+ intent: &command.IDPIntentWriteModel{
+ WriteModel: eventstore.WriteModel{
+ AggregateID: "intentID",
+ ProcessedSequence: 123,
+ ResourceOwner: "ro",
+ InstanceID: "instanceID",
+ ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local),
+ },
+ IDPID: "idpID",
+ IDPUser: []byte(`{"userID": "idpUserID", "username": "username"}`),
+ IDPUserID: "idpUserID",
+ IDPUserName: "username",
+ IDPEntryAttributes: map[string][]string{
+ "id": {"idpUserID"},
+ "firstName": {"firstname1", "firstname2"},
+ "lastName": {"lastname"},
+ },
+ UserID: "userID",
+ State: domain.IDPIntentStateSucceeded,
+ },
+ },
+ res{
+ resp: &user.RetrieveIdentityProviderIntentResponse{
+ Details: &object_pb.Details{
+ Sequence: 123,
+ ChangeDate: timestamppb.New(time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local)),
+ ResourceOwner: "ro",
+ },
+ IdpInformation: &user.IDPInformation{
+ Access: &user.IDPInformation_Ldap{
+ Ldap: &user.IDPLDAPAccessInformation{
+ Attributes: func() *structpb.Struct {
+ s, err := structpb.NewStruct(map[string]interface{}{
+ "id": []interface{}{"idpUserID"},
+ "firstName": []interface{}{"firstname1", "firstname2"},
+ "lastName": []interface{}{"lastname"},
+ })
+ require.NoError(t, err)
+ return s
+ }(),
+ },
+ },
+ IdpId: "idpID",
+ UserId: "idpUserID",
+ UserName: "username",
+ RawInformation: func() *structpb.Struct {
+ s, err := structpb.NewStruct(map[string]interface{}{
+ "userID": "idpUserID",
+ "username": "username",
+ })
+ require.NoError(t, err)
+ return s
+ }(),
+ },
+ UserId: "userID",
+ },
+ err: nil,
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := idpIntentToIDPIntentPb(tt.args.intent, tt.args.alg)
+ require.ErrorIs(t, err, tt.res.err)
+ grpc.AllFieldsEqual(t, tt.res.resp.ProtoReflect(), got.ProtoReflect(), grpc.CustomMappers)
+ })
+ }
+}
+
+func Test_authMethodTypesToPb(t *testing.T) {
+ tests := []struct {
+ name string
+ methodTypes []domain.UserAuthMethodType
+ want []user.AuthenticationMethodType
+ }{
+ {
+ "empty list",
+ nil,
+ []user.AuthenticationMethodType{},
+ },
+ {
+ "list",
+ []domain.UserAuthMethodType{
+ domain.UserAuthMethodTypePasswordless,
+ },
+ []user.AuthenticationMethodType{
+ user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY,
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equalf(t, tt.want, authMethodTypesToPb(tt.methodTypes), "authMethodTypesToPb(%v)", tt.methodTypes)
+ })
+ }
+}
+
+func Test_authMethodTypeToPb(t *testing.T) {
+ tests := []struct {
+ name string
+ methodType domain.UserAuthMethodType
+ want user.AuthenticationMethodType
+ }{
+ {
+ "uspecified",
+ domain.UserAuthMethodTypeUnspecified,
+ user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED,
+ },
+ {
+ "totp",
+ domain.UserAuthMethodTypeTOTP,
+ user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_TOTP,
+ },
+ {
+ "u2f",
+ domain.UserAuthMethodTypeU2F,
+ user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_U2F,
+ },
+ {
+ "passkey",
+ domain.UserAuthMethodTypePasswordless,
+ user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY,
+ },
+ {
+ "password",
+ domain.UserAuthMethodTypePassword,
+ user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSWORD,
+ },
+ {
+ "idp",
+ domain.UserAuthMethodTypeIDP,
+ user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP,
+ },
+ {
+ "otp sms",
+ domain.UserAuthMethodTypeOTPSMS,
+ user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_SMS,
+ },
+ {
+ "otp email",
+ domain.UserAuthMethodTypeOTPEmail,
+ user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_EMAIL,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equalf(t, tt.want, authMethodTypeToPb(tt.methodType), "authMethodTypeToPb(%v)", tt.methodType)
+ })
+ }
+}
diff --git a/internal/api/http/domain_check.go b/internal/api/http/domain_check.go
index 00ab0597dc..616c28cdfc 100644
--- a/internal/api/http/domain_check.go
+++ b/internal/api/http/domain_check.go
@@ -3,7 +3,7 @@ package http
import (
errorsAs "errors"
"fmt"
- "io/ioutil"
+ "io"
"net"
"net/http"
@@ -43,7 +43,7 @@ func ValidateDomainHTTP(domain, token, verifier string) error {
return zerrors.ThrowInternal(err, "HTTP-G2zsw", "Errors.Internal")
}
defer resp.Body.Close()
- body, err := ioutil.ReadAll(resp.Body)
+ body, err := io.ReadAll(resp.Body)
if err != nil {
return zerrors.ThrowInternal(err, "HTTP-HB432", "Errors.Internal")
}
diff --git a/internal/api/http/marshal.go b/internal/api/http/marshal.go
index 35fdda5f51..193196e07f 100644
--- a/internal/api/http/marshal.go
+++ b/internal/api/http/marshal.go
@@ -19,5 +19,5 @@ func MarshalJSON(w http.ResponseWriter, i interface{}, err error, statusCode int
}
w.Header().Set("content-type", "application/json")
_, err = w.Write(b)
- logging.Log("HTTP-sdgT2").OnError(err).Error("error writing response")
+ logging.WithFields("logID", "HTTP-sdgT2").OnError(err).Error("error writing response")
}
diff --git a/internal/api/idp/idp_integration_test.go b/internal/api/idp/idp_integration_test.go
index 8fdc24539c..51d9bbfeea 100644
--- a/internal/api/idp/idp_integration_test.go
+++ b/internal/api/idp/idp_integration_test.go
@@ -27,7 +27,7 @@ import (
http_util "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/integration"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
var (
diff --git a/internal/api/oidc/auth_request_integration_test.go b/internal/api/oidc/auth_request_integration_test.go
index 630b20bc09..faa5126676 100644
--- a/internal/api/oidc/auth_request_integration_test.go
+++ b/internal/api/oidc/auth_request_integration_test.go
@@ -18,8 +18,8 @@ import (
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/integration"
- oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
- session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
+ oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/session/v2"
)
var (
diff --git a/internal/api/oidc/client_integration_test.go b/internal/api/oidc/client_integration_test.go
index 65cc9309d5..9ff0b104d9 100644
--- a/internal/api/oidc/client_integration_test.go
+++ b/internal/api/oidc/client_integration_test.go
@@ -20,7 +20,7 @@ import (
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/authn"
"github.com/zitadel/zitadel/pkg/grpc/management"
- oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
+ oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
)
func TestServer_Introspect(t *testing.T) {
diff --git a/internal/api/oidc/oidc_integration_test.go b/internal/api/oidc/oidc_integration_test.go
index 0baeb53363..1f7f615809 100644
--- a/internal/api/oidc/oidc_integration_test.go
+++ b/internal/api/oidc/oidc_integration_test.go
@@ -19,9 +19,9 @@ import (
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/auth"
mgmt "github.com/zitadel/zitadel/pkg/grpc/management"
- oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
- session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/session/v2"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
var (
diff --git a/internal/api/oidc/token_exchange_integration_test.go b/internal/api/oidc/token_exchange_integration_test.go
index 2636b85d86..b8b4b0bbfe 100644
--- a/internal/api/oidc/token_exchange_integration_test.go
+++ b/internal/api/oidc/token_exchange_integration_test.go
@@ -22,7 +22,7 @@ import (
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/admin"
- feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/feature/v2"
)
func setTokenExchangeFeature(t *testing.T, value bool) {
diff --git a/internal/api/oidc/userinfo_integration_test.go b/internal/api/oidc/userinfo_integration_test.go
index 350b2267d3..ad95defd6f 100644
--- a/internal/api/oidc/userinfo_integration_test.go
+++ b/internal/api/oidc/userinfo_integration_test.go
@@ -18,9 +18,9 @@ import (
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/integration"
- feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/feature/v2"
"github.com/zitadel/zitadel/pkg/grpc/management"
- oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
+ oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
)
// TestServer_UserInfo is a top-level test which re-executes the actual
diff --git a/internal/command/org_idp_config_test.go b/internal/command/org_idp_config_test.go
index 2897997695..eb38ca962c 100644
--- a/internal/command/org_idp_config_test.go
+++ b/internal/command/org_idp_config_test.go
@@ -398,7 +398,8 @@ func newIDPConfigChangedEvent(ctx context.Context, orgID, configID, oldName, new
func TestCommands_RemoveIDPConfig(t *testing.T) {
type fields struct {
- eventstore *eventstore.Eventstore
+ eventstore *eventstore.Eventstore
+ checkPermission domain.PermissionCheck
}
type args struct {
ctx context.Context
@@ -423,6 +424,7 @@ func TestCommands_RemoveIDPConfig(t *testing.T) {
eventstore: eventstoreExpect(t,
expectFilter(),
),
+ checkPermission: newMockPermissionCheckAllowed(),
},
args{
context.Background(),
@@ -460,6 +462,7 @@ func TestCommands_RemoveIDPConfig(t *testing.T) {
),
),
),
+ checkPermission: newMockPermissionCheckAllowed(),
},
args{
context.Background(),
@@ -532,6 +535,84 @@ func TestCommands_RemoveIDPConfig(t *testing.T) {
),
),
),
+ checkPermission: newMockPermissionCheckAllowed(),
+ },
+ args{
+ context.Background(),
+ "idp1",
+ "org1",
+ true,
+ []*domain.UserIDPLink{
+ {
+ ObjectRoot: models.ObjectRoot{
+ AggregateID: "user1",
+ },
+ IDPConfigID: "idp1",
+ ExternalUserID: "id1",
+ DisplayName: "name",
+ },
+ },
+ },
+ res{
+ &domain.ObjectDetails{
+ ResourceOwner: "org1",
+ },
+ nil,
+ },
+ },
+ {
+ "cascade, permission error",
+ fields{
+ eventstore: eventstoreExpect(t,
+ expectFilter(
+ eventFromEventPusher(
+ org.NewIDPConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "idp1",
+ "name1",
+ domain.IDPConfigTypeOIDC,
+ domain.IDPConfigStylingTypeGoogle,
+ false,
+ ),
+ ),
+ ),
+ expectFilter(
+ eventFromEventPusher(
+ user.NewHumanAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "nickname",
+ "displayName",
+ language.German,
+ domain.GenderUnspecified,
+ "email@test.com",
+ true,
+ ),
+ ),
+ eventFromEventPusher(
+ user.NewUserIDPLinkAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "idp1",
+ "name",
+ "id1",
+ ),
+ ),
+ ),
+ expectPush(
+ org.NewIDPConfigRemovedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "idp1",
+ "name1",
+ ),
+ org.NewIdentityProviderCascadeRemovedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "idp1",
+ ),
+ ),
+ ),
+ checkPermission: newMockPermissionCheckNotAllowed(),
},
args{
context.Background(),
@@ -560,7 +641,8 @@ func TestCommands_RemoveIDPConfig(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
- eventstore: tt.fields.eventstore,
+ eventstore: tt.fields.eventstore,
+ checkPermission: tt.fields.checkPermission,
}
got, err := c.RemoveIDPConfig(tt.args.ctx, tt.args.idpID, tt.args.orgID, tt.args.cascadeRemoveProvider, tt.args.cascadeExternalIDPs...)
if tt.res.err == nil {
diff --git a/internal/command/user_human_webauthn.go b/internal/command/user_human_webauthn.go
index 6988b0052a..3555466359 100644
--- a/internal/command/user_human_webauthn.go
+++ b/internal/command/user_human_webauthn.go
@@ -603,6 +603,11 @@ func (c *Commands) removeHumanWebAuthN(ctx context.Context, userID, webAuthNID,
if existingWebAuthN.State == domain.MFAStateUnspecified || existingWebAuthN.State == domain.MFAStateRemoved {
return nil, zerrors.ThrowNotFound(nil, "COMMAND-DAfb2", "Errors.User.WebAuthN.NotFound")
}
+ if userID != authz.GetCtxData(ctx).UserID {
+ if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingWebAuthN.ResourceOwner, existingWebAuthN.AggregateID); err != nil {
+ return nil, err
+ }
+ }
userAgg := UserAggregateFromWriteModel(&existingWebAuthN.WriteModel)
pushedEvents, err := c.eventstore.Push(ctx, preparedEvent(userAgg))
diff --git a/internal/command/user_idp_link.go b/internal/command/user_idp_link.go
index 14f05964a1..761cebb7d2 100644
--- a/internal/command/user_idp_link.go
+++ b/internal/command/user_idp_link.go
@@ -126,6 +126,11 @@ func (c *Commands) removeUserIDPLink(ctx context.Context, link *domain.UserIDPLi
if existingLink.State == domain.UserIDPLinkStateUnspecified || existingLink.State == domain.UserIDPLinkStateRemoved {
return nil, nil, zerrors.ThrowNotFound(nil, "COMMAND-1M9xR", "Errors.User.ExternalIDP.NotFound")
}
+ if existingLink.AggregateID != authz.GetCtxData(ctx).UserID {
+ if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingLink.ResourceOwner, existingLink.AggregateID); err != nil {
+ return nil, nil, err
+ }
+ }
userAgg := UserAggregateFromWriteModel(&existingLink.WriteModel)
if cascade {
return user.NewUserIDPLinkCascadeRemovedEvent(ctx, userAgg, link.IDPConfigID, link.ExternalUserID), existingLink, nil
diff --git a/internal/command/user_idp_link_test.go b/internal/command/user_idp_link_test.go
index f1f7929686..67d1c005ef 100644
--- a/internal/command/user_idp_link_test.go
+++ b/internal/command/user_idp_link_test.go
@@ -519,7 +519,8 @@ func TestCommandSide_BulkAddUserIDPLinks(t *testing.T) {
func TestCommandSide_RemoveUserIDPLink(t *testing.T) {
type fields struct {
- eventstore *eventstore.Eventstore
+ eventstore *eventstore.Eventstore
+ checkPermission domain.PermissionCheck
}
type args struct {
ctx context.Context
@@ -541,6 +542,7 @@ func TestCommandSide_RemoveUserIDPLink(t *testing.T) {
eventstore: eventstoreExpect(
t,
),
+ checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: context.Background(),
@@ -562,6 +564,7 @@ func TestCommandSide_RemoveUserIDPLink(t *testing.T) {
eventstore: eventstoreExpect(
t,
),
+ checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: context.Background(),
@@ -598,6 +601,7 @@ func TestCommandSide_RemoveUserIDPLink(t *testing.T) {
),
),
),
+ checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: context.Background(),
@@ -620,6 +624,7 @@ func TestCommandSide_RemoveUserIDPLink(t *testing.T) {
t,
expectFilter(),
),
+ checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: context.Background(),
@@ -635,6 +640,38 @@ func TestCommandSide_RemoveUserIDPLink(t *testing.T) {
err: zerrors.IsNotFound,
},
},
+ {
+ name: "remove external idp, permission error",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(
+ eventFromEventPusher(
+ user.NewUserIDPLinkAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "config1",
+ "name",
+ "externaluser1",
+ ),
+ ),
+ ),
+ ),
+ checkPermission: newMockPermissionCheckNotAllowed(),
+ },
+ args: args{
+ ctx: context.Background(),
+ link: &domain.UserIDPLink{
+ ObjectRoot: models.ObjectRoot{
+ AggregateID: "user1",
+ },
+ IDPConfigID: "config1",
+ ExternalUserID: "externaluser1",
+ },
+ },
+ res: res{
+ err: zerrors.IsPermissionDenied,
+ },
+ },
{
name: "remove external idp, ok",
fields: fields{
@@ -658,6 +695,7 @@ func TestCommandSide_RemoveUserIDPLink(t *testing.T) {
),
),
),
+ checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: context.Background(),
@@ -679,7 +717,8 @@ func TestCommandSide_RemoveUserIDPLink(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Commands{
- eventstore: tt.fields.eventstore,
+ eventstore: tt.fields.eventstore,
+ checkPermission: tt.fields.checkPermission,
}
got, err := r.RemoveUserIDPLink(tt.args.ctx, tt.args.link)
if tt.res.err == nil {
diff --git a/internal/config/config.go b/internal/config/config.go
index b454cea41f..a0e7eceb3b 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -2,7 +2,6 @@ package config
import (
"encoding/json"
- "io/ioutil"
"os"
"path/filepath"
@@ -50,7 +49,7 @@ func Read(obj interface{}, configFiles ...string) error {
func readConfigFile(readerFunc ReaderFunc, configFile string, obj interface{}) error {
configFile = os.ExpandEnv(configFile)
- configStr, err := ioutil.ReadFile(configFile)
+ configStr, err := os.ReadFile(configFile)
if err != nil {
return zerrors.ThrowInternalf(err, "CONFI-nJk2a", "failed to read config file %s", configFile)
}
diff --git a/internal/integration/assert.go b/internal/integration/assert.go
index 610c48cf31..6928054e8e 100644
--- a/internal/integration/assert.go
+++ b/internal/integration/assert.go
@@ -9,8 +9,6 @@ import (
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
-
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
)
// Details is the interface that covers both v1 and v2 proto generated object details.
@@ -26,8 +24,14 @@ type DetailsMsg[D Details] interface {
GetDetails() D
}
-type ListDetailsMsg interface {
- GetDetails() *object.ListDetails
+type ListDetails interface {
+ comparable
+ GetTotalResult() uint64
+ GetTimestamp() *timestamppb.Timestamp
+}
+
+type ListDetailsMsg[L ListDetails] interface {
+ GetDetails() L
}
// AssertDetails asserts values in a message's object Details,
@@ -59,13 +63,13 @@ func AssertDetails[D Details, M DetailsMsg[D]](t testing.TB, expected, actual M)
assert.Equal(t, wantDetails.GetResourceOwner(), gotDetails.GetResourceOwner())
}
-func AssertListDetails[D ListDetailsMsg](t testing.TB, expected, actual D) {
+func AssertListDetails[L ListDetails, D ListDetailsMsg[L]](t testing.TB, expected, actual D) {
wantDetails, gotDetails := expected.GetDetails(), actual.GetDetails()
- if wantDetails == nil {
+ var nilDetails L
+ if wantDetails == nilDetails {
assert.Nil(t, gotDetails)
return
}
-
assert.Equal(t, wantDetails.GetTotalResult(), gotDetails.GetTotalResult())
if wantDetails.GetTimestamp() != nil {
diff --git a/internal/integration/client.go b/internal/integration/client.go
index bd7c8eb400..7cb3af4c7b 100644
--- a/internal/integration/client.go
+++ b/internal/integration/client.go
@@ -28,51 +28,69 @@ import (
action "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha"
"github.com/zitadel/zitadel/pkg/grpc/admin"
"github.com/zitadel/zitadel/pkg/grpc/auth"
- feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/feature/v2"
+ feature_v2beta "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta"
mgmt "github.com/zitadel/zitadel/pkg/grpc/management"
- object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta"
- oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
- org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta"
- organisation "github.com/zitadel/zitadel/pkg/grpc/org/v2beta"
- session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
- settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/object/v2"
+ oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
+ oidc_pb_v2beta "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/org/v2"
+ organisation "github.com/zitadel/zitadel/pkg/grpc/org/v2"
+ org_v2beta "github.com/zitadel/zitadel/pkg/grpc/org/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/session/v2"
+ session_v2beta "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/settings/v2"
+ settings_v2beta "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta"
"github.com/zitadel/zitadel/pkg/grpc/system"
user_pb "github.com/zitadel/zitadel/pkg/grpc/user"
schema "github.com/zitadel/zitadel/pkg/grpc/user/schema/v3alpha"
- user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
+ "github.com/zitadel/zitadel/pkg/grpc/user/v2"
+ user_v2beta "github.com/zitadel/zitadel/pkg/grpc/user/v2beta"
)
type Client struct {
- CC *grpc.ClientConn
- Admin admin.AdminServiceClient
- Mgmt mgmt.ManagementServiceClient
- Auth auth.AuthServiceClient
- UserV2 user.UserServiceClient
- SessionV2 session.SessionServiceClient
- SettingsV2 settings.SettingsServiceClient
- OIDCv2 oidc_pb.OIDCServiceClient
- OrgV2 organisation.OrganizationServiceClient
- System system.SystemServiceClient
- ActionV3 action.ActionServiceClient
- FeatureV2 feature.FeatureServiceClient
- UserSchemaV3 schema.UserSchemaServiceClient
+ CC *grpc.ClientConn
+ Admin admin.AdminServiceClient
+ Mgmt mgmt.ManagementServiceClient
+ Auth auth.AuthServiceClient
+ UserV2beta user_v2beta.UserServiceClient
+ UserV2 user.UserServiceClient
+ SessionV2beta session_v2beta.SessionServiceClient
+ SessionV2 session.SessionServiceClient
+ SettingsV2beta settings_v2beta.SettingsServiceClient
+ SettingsV2 settings.SettingsServiceClient
+ OIDCv2beta oidc_pb_v2beta.OIDCServiceClient
+ OIDCv2 oidc_pb.OIDCServiceClient
+ OrgV2beta org_v2beta.OrganizationServiceClient
+ OrgV2 organisation.OrganizationServiceClient
+ System system.SystemServiceClient
+ ActionV3 action.ActionServiceClient
+ FeatureV2beta feature_v2beta.FeatureServiceClient
+ FeatureV2 feature.FeatureServiceClient
+ UserSchemaV3 schema.UserSchemaServiceClient
}
func newClient(cc *grpc.ClientConn) Client {
return Client{
- CC: cc,
- Admin: admin.NewAdminServiceClient(cc),
- Mgmt: mgmt.NewManagementServiceClient(cc),
- Auth: auth.NewAuthServiceClient(cc),
- UserV2: user.NewUserServiceClient(cc),
- SessionV2: session.NewSessionServiceClient(cc),
- SettingsV2: settings.NewSettingsServiceClient(cc),
- OIDCv2: oidc_pb.NewOIDCServiceClient(cc),
- OrgV2: organisation.NewOrganizationServiceClient(cc),
- System: system.NewSystemServiceClient(cc),
- ActionV3: action.NewActionServiceClient(cc),
- FeatureV2: feature.NewFeatureServiceClient(cc),
- UserSchemaV3: schema.NewUserSchemaServiceClient(cc),
+ CC: cc,
+ Admin: admin.NewAdminServiceClient(cc),
+ Mgmt: mgmt.NewManagementServiceClient(cc),
+ Auth: auth.NewAuthServiceClient(cc),
+ UserV2beta: user_v2beta.NewUserServiceClient(cc),
+ UserV2: user.NewUserServiceClient(cc),
+ SessionV2beta: session_v2beta.NewSessionServiceClient(cc),
+ SessionV2: session.NewSessionServiceClient(cc),
+ SettingsV2beta: settings_v2beta.NewSettingsServiceClient(cc),
+ SettingsV2: settings.NewSettingsServiceClient(cc),
+ OIDCv2beta: oidc_pb_v2beta.NewOIDCServiceClient(cc),
+ OIDCv2: oidc_pb.NewOIDCServiceClient(cc),
+ OrgV2beta: org_v2beta.NewOrganizationServiceClient(cc),
+ OrgV2: organisation.NewOrganizationServiceClient(cc),
+ System: system.NewSystemServiceClient(cc),
+ ActionV3: action.NewActionServiceClient(cc),
+ FeatureV2beta: feature_v2beta.NewFeatureServiceClient(cc),
+ FeatureV2: feature.NewFeatureServiceClient(cc),
+ UserSchemaV3: schema.NewUserSchemaServiceClient(cc),
}
}
diff --git a/internal/notification/channels/log/channel.go b/internal/notification/channels/log/channel.go
index ecdb59c642..42efbbe842 100644
--- a/internal/notification/channels/log/channel.go
+++ b/internal/notification/channels/log/channel.go
@@ -11,7 +11,7 @@ import (
func InitStdoutChannel(config Config) channels.NotificationChannel {
- logging.Log("NOTIF-D0164").Debug("successfully initialized stdout email and sms channel")
+ logging.WithFields("logID", "NOTIF-D0164").Debug("successfully initialized stdout email and sms channel")
return channels.HandleMessageFunc(func(message channels.Message) error {
@@ -23,7 +23,7 @@ func InitStdoutChannel(config Config) channels.NotificationChannel {
content = html2text.HTML2Text(content)
}
- logging.Log("NOTIF-c73ba").WithFields(map[string]interface{}{
+ logging.WithFields("logID", "NOTIF-c73ba").WithFields(map[string]interface{}{
"type": fmt.Sprintf("%T", message),
"content": content,
}).Info("handling notification message")
diff --git a/internal/notification/handlers/telemetry_pusher_integration_test.go b/internal/notification/handlers/telemetry_pusher_integration_test.go
index b84f02fa79..8f207b9de3 100644
--- a/internal/notification/handlers/telemetry_pusher_integration_test.go
+++ b/internal/notification/handlers/telemetry_pusher_integration_test.go
@@ -19,7 +19,7 @@ import (
"github.com/zitadel/zitadel/pkg/grpc/app"
"github.com/zitadel/zitadel/pkg/grpc/management"
"github.com/zitadel/zitadel/pkg/grpc/object"
- oidc_v2 "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
+ oidc_v2 "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
"github.com/zitadel/zitadel/pkg/grpc/project"
"github.com/zitadel/zitadel/pkg/grpc/system"
)
diff --git a/internal/query/idp_user_link.go b/internal/query/idp_user_link.go
index 50e780c372..4f13d9d315 100644
--- a/internal/query/idp_user_link.go
+++ b/internal/query/idp_user_link.go
@@ -89,6 +89,29 @@ var (
}
)
+func (l *IDPUserLinks) RemoveNoPermission(ctx context.Context, permissionCheck domain.PermissionCheck) {
+ removableIndexes := make([]int, 0)
+ for i := range l.Links {
+ ctxData := authz.GetCtxData(ctx)
+ if ctxData.UserID != l.Links[i].UserID {
+ if err := permissionCheck(ctx, domain.PermissionUserRead, l.Links[i].ResourceOwner, l.Links[i].UserID); err != nil {
+ removableIndexes = append(removableIndexes, i)
+ }
+ }
+ }
+ removed := 0
+ for _, removeIndex := range removableIndexes {
+ l.Links = removeIDPLink(l.Links, removeIndex-removed)
+ removed++
+ }
+ // reset count as some users could be removed
+ l.SearchResponse.Count = uint64(len(l.Links))
+}
+
+func removeIDPLink(slice []*IDPUserLink, s int) []*IDPUserLink {
+ return append(slice[:s], slice[s+1:]...)
+}
+
func (q *Queries) IDPUserLinks(ctx context.Context, queries *IDPUserLinksSearchQuery, withOwnerRemoved bool) (idps *IDPUserLinks, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
diff --git a/internal/query/user_auth_method.go b/internal/query/user_auth_method.go
index 243c465a84..6a93f069b0 100644
--- a/internal/query/user_auth_method.go
+++ b/internal/query/user_auth_method.go
@@ -98,6 +98,29 @@ type AuthMethods struct {
AuthMethods []*AuthMethod
}
+func (l *AuthMethods) RemoveNoPermission(ctx context.Context, permissionCheck domain.PermissionCheck) {
+ removableIndexes := make([]int, 0)
+ for i := range l.AuthMethods {
+ ctxData := authz.GetCtxData(ctx)
+ if ctxData.UserID != l.AuthMethods[i].UserID {
+ if err := permissionCheck(ctx, domain.PermissionUserRead, l.AuthMethods[i].ResourceOwner, l.AuthMethods[i].UserID); err != nil {
+ removableIndexes = append(removableIndexes, i)
+ }
+ }
+ }
+ removed := 0
+ for _, removeIndex := range removableIndexes {
+ l.AuthMethods = removeAuthMethod(l.AuthMethods, removeIndex-removed)
+ removed++
+ }
+ // reset count as some users could be removed
+ l.SearchResponse.Count = uint64(len(l.AuthMethods))
+}
+
+func removeAuthMethod(slice []*AuthMethod, s int) []*AuthMethod {
+ return append(slice[:s], slice[s+1:]...)
+}
+
type AuthMethod struct {
UserID string
CreationDate time.Time
diff --git a/internal/repository/user/machine_key.go b/internal/repository/user/machine_key.go
index aff1c3750e..b532616883 100644
--- a/internal/repository/user/machine_key.go
+++ b/internal/repository/user/machine_key.go
@@ -60,8 +60,9 @@ func MachineKeyAddedEventMapper(event eventstore.Event) (eventstore.Event, error
}
err := event.Unmarshal(machineKeyAdded)
if err != nil {
- //first events had wrong payload.
+ // first events had wrong payload.
// the keys were removed later, that's why we ignore them here.
+ //nolint:errorlint
if unwrapErr, ok := err.(*json.UnmarshalTypeError); ok && unwrapErr.Field == "publicKey" {
return machineKeyAdded, nil
}
diff --git a/internal/telemetry/tracing/caller.go b/internal/telemetry/tracing/caller.go
index da9cc2940b..a7612cab8d 100644
--- a/internal/telemetry/tracing/caller.go
+++ b/internal/telemetry/tracing/caller.go
@@ -10,12 +10,12 @@ func GetCaller() string {
fpcs := make([]uintptr, 1)
n := runtime.Callers(3, fpcs)
if n == 0 {
- logging.Log("TRACE-rWjfC").Debug("no caller")
+ logging.WithFields("logID", "TRACE-rWjfC").Debug("no caller")
return ""
}
caller := runtime.FuncForPC(fpcs[0] - 1)
if caller == nil {
- logging.Log("TRACE-25POw").Debug("caller was nil")
+ logging.WithFields("logID", "TRACE-25POw").Debug("caller was nil")
return ""
}
return caller.Name()
diff --git a/pkg/grpc/user/v2/user.go b/pkg/grpc/user/v2/user.go
new file mode 100644
index 0000000000..ec9245c8eb
--- /dev/null
+++ b/pkg/grpc/user/v2/user.go
@@ -0,0 +1,3 @@
+package user
+
+type UserType = isUser_Type
diff --git a/proto/zitadel/action/v3alpha/action_service.proto b/proto/zitadel/action/v3alpha/action_service.proto
index da174b45d0..938c9e88fc 100644
--- a/proto/zitadel/action/v3alpha/action_service.proto
+++ b/proto/zitadel/action/v3alpha/action_service.proto
@@ -11,7 +11,7 @@ import "validate/validate.proto";
import "zitadel/action/v3alpha/target.proto";
import "zitadel/action/v3alpha/execution.proto";
import "zitadel/action/v3alpha/query.proto";
-import "zitadel/object/v2beta/object.proto";
+import "zitadel/object/v2/object.proto";
import "zitadel/protoc_gen_zitadel/v2/options.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha;action";
@@ -449,7 +449,7 @@ message CreateTargetResponse {
// ID is the read-only unique identifier of the target.
string id = 1;
// Details provide some base information (such as the last change date) of the target.
- zitadel.object.v2beta.Details details = 2;
+ zitadel.object.v2.Details details = 2;
}
message UpdateTargetRequest {
@@ -498,7 +498,7 @@ message UpdateTargetRequest {
message UpdateTargetResponse {
// Details provide some base information (such as the last change date) of the target.
- zitadel.object.v2beta.Details details = 1;
+ zitadel.object.v2.Details details = 1;
}
message DeleteTargetRequest {
@@ -516,12 +516,12 @@ message DeleteTargetRequest {
message DeleteTargetResponse {
// Details provide some base information (such as the last change date) of the target.
- zitadel.object.v2beta.Details details = 1;
+ zitadel.object.v2.Details details = 1;
}
message ListTargetsRequest {
// list limitations and ordering.
- zitadel.object.v2beta.ListQuery query = 1;
+ zitadel.object.v2.ListQuery query = 1;
// the field the result is sorted.
zitadel.action.v3alpha.TargetFieldName sorting_column = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
@@ -534,7 +534,7 @@ message ListTargetsRequest {
message ListTargetsResponse {
// Details provides information about the returned result including total amount found.
- zitadel.object.v2beta.ListDetails details = 1;
+ zitadel.object.v2.ListDetails details = 1;
// States by which field the results are sorted.
zitadel.action.v3alpha.TargetFieldName sorting_column = 2;
// The result contains the user schemas, which matched the queries.
@@ -567,7 +567,7 @@ message SetExecutionRequest {
message SetExecutionResponse {
// Details provide some base information (such as the last change date) of the execution.
- zitadel.object.v2beta.Details details = 2;
+ zitadel.object.v2.Details details = 2;
}
message DeleteExecutionRequest {
@@ -577,19 +577,19 @@ message DeleteExecutionRequest {
message DeleteExecutionResponse {
// Details provide some base information (such as the last change date) of the execution.
- zitadel.object.v2beta.Details details = 1;
+ zitadel.object.v2.Details details = 1;
}
message ListExecutionsRequest {
// list limitations and ordering.
- zitadel.object.v2beta.ListQuery query = 1;
+ zitadel.object.v2.ListQuery query = 1;
// Define the criteria to query for.
repeated zitadel.action.v3alpha.SearchQuery queries = 2;
}
message ListExecutionsResponse {
// Details provides information about the returned result including total amount found.
- zitadel.object.v2beta.ListDetails details = 1;
+ zitadel.object.v2.ListDetails details = 1;
// The result contains the executions, which matched the queries.
repeated zitadel.action.v3alpha.Execution result = 2;
}
diff --git a/proto/zitadel/action/v3alpha/execution.proto b/proto/zitadel/action/v3alpha/execution.proto
index 6f24471185..797f997cd8 100644
--- a/proto/zitadel/action/v3alpha/execution.proto
+++ b/proto/zitadel/action/v3alpha/execution.proto
@@ -8,7 +8,7 @@ import "google/protobuf/duration.proto";
import "google/protobuf/struct.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
-import "zitadel/object/v2beta/object.proto";
+import "zitadel/object/v2/object.proto";
import "zitadel/protoc_gen_zitadel/v2/options.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha;action";
@@ -16,7 +16,7 @@ option go_package = "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha;action";
message Execution {
Condition Condition = 1;
// Details provide some base information (such as the last change date) of the target.
- zitadel.object.v2beta.Details details = 2;
+ zitadel.object.v2.Details details = 2;
// List of ordered list of targets/includes called during the execution.
repeated ExecutionTargetType targets = 3;
}
@@ -55,7 +55,7 @@ message RequestExecution {
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1,
max_length: 1000,
- example: "\"/zitadel.session.v2beta.SessionService/ListSessions\"";
+ example: "\"/zitadel.session.v2.SessionService/ListSessions\"";
}
];
// GRPC-service as condition.
@@ -64,7 +64,7 @@ message RequestExecution {
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1,
max_length: 1000,
- example: "\"zitadel.session.v2beta.SessionService\"";
+ example: "\"zitadel.session.v2.SessionService\"";
}
];
// All calls to any available service and endpoint as condition.
@@ -81,7 +81,7 @@ message ResponseExecution {
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1,
max_length: 1000,
- example: "\"/zitadel.session.v2beta.SessionService/ListSessions\"";
+ example: "\"/zitadel.session.v2.SessionService/ListSessions\"";
}
];
// GRPC-service as condition.
@@ -90,7 +90,7 @@ message ResponseExecution {
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1,
max_length: 1000,
- example: "\"zitadel.session.v2beta.SessionService\"";
+ example: "\"zitadel.session.v2.SessionService\"";
}
];
// All calls to any available service and endpoint as condition.
diff --git a/proto/zitadel/action/v3alpha/query.proto b/proto/zitadel/action/v3alpha/query.proto
index a32caacfba..e32bda1d84 100644
--- a/proto/zitadel/action/v3alpha/query.proto
+++ b/proto/zitadel/action/v3alpha/query.proto
@@ -7,7 +7,7 @@ option go_package = "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha;action";
import "google/api/field_behavior.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
-import "zitadel/object/v2beta/object.proto";
+import "zitadel/object/v2/object.proto";
import "zitadel/action/v3alpha/execution.proto";
message SearchQuery {
@@ -46,7 +46,7 @@ message IncludeQuery {
Condition include = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "the id of the include"
- example: "\"request.zitadel.session.v2beta.SessionService\"";
+ example: "\"request.zitadel.session.v2.SessionService\"";
}
];
}
@@ -70,7 +70,7 @@ message TargetNameQuery {
}
];
// Defines which text comparison method used for the name query.
- zitadel.object.v2beta.TextQueryMethod method = 2 [
+ zitadel.object.v2.TextQueryMethod method = 2 [
(validate.rules).enum.defined_only = true,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "defines which text equality method is used";
diff --git a/proto/zitadel/action/v3alpha/target.proto b/proto/zitadel/action/v3alpha/target.proto
index 92dda32bbb..bea5a4b756 100644
--- a/proto/zitadel/action/v3alpha/target.proto
+++ b/proto/zitadel/action/v3alpha/target.proto
@@ -8,7 +8,7 @@ import "google/protobuf/duration.proto";
import "google/protobuf/struct.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
-import "zitadel/object/v2beta/object.proto";
+import "zitadel/object/v2/object.proto";
import "zitadel/protoc_gen_zitadel/v2/options.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha;action";
@@ -36,7 +36,7 @@ message Target {
}
];
// Details provide some base information (such as the last change date) of the target.
- zitadel.object.v2beta.Details details = 2;
+ zitadel.object.v2.Details details = 2;
// Unique name of the target.
string name = 3 [
diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto
index 64ad68c53a..e02e23da6b 100644
--- a/proto/zitadel/management.proto
+++ b/proto/zitadel/management.proto
@@ -1005,6 +1005,7 @@ service ManagementService {
}
// Deprecated: not used anymore in user state
+ // To resend a verification email use the user service v2 ResendEmailCode
rpc ResendHumanInitialization(ResendHumanInitializationRequest) returns (ResendHumanInitializationResponse) {
option (google.api.http) = {
post: "/users/{user_id}/_resend_initialization"
diff --git a/proto/zitadel/user/schema/v3alpha/user_schema.proto b/proto/zitadel/user/schema/v3alpha/user_schema.proto
index c75d7d8194..e7a7a0737a 100644
--- a/proto/zitadel/user/schema/v3alpha/user_schema.proto
+++ b/proto/zitadel/user/schema/v3alpha/user_schema.proto
@@ -6,7 +6,7 @@ import "google/api/field_behavior.proto";
import "google/protobuf/struct.proto";
import "validate/validate.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
-import "zitadel/object/v2beta/object.proto";
+import "zitadel/object/v2/object.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/schema/v3alpha";
@@ -19,7 +19,7 @@ message UserSchema {
}
];
// Details provide some base information (such as the last change date) of the schema.
- zitadel.object.v2beta.Details details = 2;
+ zitadel.object.v2.Details details = 2;
// Type is a human readable text describing the schema.
string type = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
@@ -119,7 +119,7 @@ message IDQuery {
}
];
// Defines which text comparison method used for the id query.
- zitadel.object.v2beta.TextQueryMethod method = 2 [
+ zitadel.object.v2.TextQueryMethod method = 2 [
(validate.rules).enum.defined_only = true
];
}
@@ -135,7 +135,7 @@ message TypeQuery {
}
];
// Defines which text comparison method used for the type query.
- zitadel.object.v2beta.TextQueryMethod method = 2 [
+ zitadel.object.v2.TextQueryMethod method = 2 [
(validate.rules).enum.defined_only = true
];
}
diff --git a/proto/zitadel/user/schema/v3alpha/user_schema_service.proto b/proto/zitadel/user/schema/v3alpha/user_schema_service.proto
index f9d2181dbf..14e59f1eab 100644
--- a/proto/zitadel/user/schema/v3alpha/user_schema_service.proto
+++ b/proto/zitadel/user/schema/v3alpha/user_schema_service.proto
@@ -8,7 +8,7 @@ import "google/protobuf/duration.proto";
import "google/protobuf/struct.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
-import "zitadel/object/v2beta/object.proto";
+import "zitadel/object/v2/object.proto";
import "zitadel/protoc_gen_zitadel/v2/options.proto";
import "zitadel/user/schema/v3alpha/user_schema.proto";
@@ -299,7 +299,7 @@ service UserSchemaService {
message ListUserSchemasRequest {
// list limitations and ordering.
- zitadel.object.v2beta.ListQuery query = 1;
+ zitadel.object.v2.ListQuery query = 1;
// the field the result is sorted.
zitadel.user.schema.v3alpha.FieldName sorting_column = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
@@ -312,7 +312,7 @@ message ListUserSchemasRequest {
message ListUserSchemasResponse {
// Details provides information about the returned result including total amount found.
- zitadel.object.v2beta.ListDetails details = 1;
+ zitadel.object.v2.ListDetails details = 1;
// States by which field the results are sorted.
zitadel.user.schema.v3alpha.FieldName sorting_column = 2;
// The result contains the user schemas, which matched the queries.
@@ -376,7 +376,7 @@ message CreateUserSchemaResponse {
// ID is the read-only unique identifier of the schema.
string id = 1;
// Details provide some base information (such as the last change date) of the schema.
- zitadel.object.v2beta.Details details = 2;
+ zitadel.object.v2.Details details = 2;
}
@@ -416,7 +416,7 @@ message UpdateUserSchemaRequest {
message UpdateUserSchemaResponse {
// Details provide some base information (such as the last change date) of the schema.
- zitadel.object.v2beta.Details details = 1;
+ zitadel.object.v2.Details details = 1;
}
message DeactivateUserSchemaRequest {
@@ -426,7 +426,7 @@ message DeactivateUserSchemaRequest {
message DeactivateUserSchemaResponse {
// Details provide some base information (such as the last change date) of the schema.
- zitadel.object.v2beta.Details details = 1;
+ zitadel.object.v2.Details details = 1;
}
message ReactivateUserSchemaRequest {
@@ -436,7 +436,7 @@ message ReactivateUserSchemaRequest {
message ReactivateUserSchemaResponse {
// Details provide some base information (such as the last change date) of the schema.
- zitadel.object.v2beta.Details details = 1;
+ zitadel.object.v2.Details details = 1;
}
message DeleteUserSchemaRequest {
@@ -446,7 +446,7 @@ message DeleteUserSchemaRequest {
message DeleteUserSchemaResponse {
// Details provide some base information (such as the last change date) of the schema.
- zitadel.object.v2beta.Details details = 1;
+ zitadel.object.v2.Details details = 1;
}
diff --git a/statik/doc.go b/statik/doc.go
index bbcf4923da..6cbe78ef8d 100644
--- a/statik/doc.go
+++ b/statik/doc.go
@@ -1,2 +1,2 @@
-//Package statik is needed for building migration files into binary
+// Package statik is needed for building migration files into binary
package statik