From cddbd3dd47d559dde2e2deb63be25bc93137826b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabienne=20B=C3=BChler?= Date: Tue, 17 Jun 2025 15:20:44 +0200 Subject: [PATCH 01/66] docs: Correct API docs of unlock user (#10064) # Which Problems Are Solved The API docs of unlock user show the description of the lock user. # How the Problems Are Solved Correct API docs for unlock user are added --- proto/zitadel/user/v2/user_service.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 3fc81836d6..a416555905 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -701,7 +701,7 @@ service UserService { // Unlock user // - // The state of the user will be changed to 'locked'. The user will not be able to log in anymore. The endpoint returns an error if the user is already in the state 'locked'. Use this endpoint if the user should not be able to log in temporarily because of an event that happened (wrong password, etc.).. + // The state of the user will be changed to 'active'. The user will be able to log in again. The endpoint returns an error if the user is not in the state 'locked'. rpc UnlockUser(UnlockUserRequest) returns (UnlockUserResponse) { option (google.api.http) = { post: "/v2/users/{user_id}/unlock" From 28f7218ea1068a4bc7587166ce3a17af20ff4156 Mon Sep 17 00:00:00 2001 From: "Marco A." Date: Wed, 18 Jun 2025 13:24:39 +0200 Subject: [PATCH 02/66] feat: Hosted login translation API (#10011) # Which Problems Are Solved This PR implements https://github.com/zitadel/zitadel/issues/9850 # How the Problems Are Solved - New protobuf definition - Implementation of retrieval of system translations - Implementation of retrieval and persistence of organization and instance level translations # Additional Context - Closes #9850 # TODO - [x] Integration tests for Get and Set hosted login translation endpoints - [x] DB migration test - [x] Command function tests - [x] Command util functions tests - [x] Query function test - [x] Query util functions tests --- go.mod | 3 +- go.sum | 2 + internal/api/authz/context_mock.go | 24 +- .../v2/integration_test/query_test.go | 432 +++++ .../v2/integration_test/server_test.go | 9 +- .../v2/integration_test/settings_test.go | 371 +--- internal/api/grpc/settings/v2/query.go | 209 +++ internal/api/grpc/settings/v2/settings.go | 201 +-- internal/command/hosted_login_translation.go | 73 + .../command/hosted_login_translation_model.go | 45 + .../command/hosted_login_translation_test.go | 211 +++ internal/database/mock/sql_mock.go | 12 +- internal/query/hosted_login_translation.go | 256 +++ .../query/hosted_login_translation_test.go | 337 ++++ .../projection/hosted_login_translation.go | 144 ++ internal/query/projection/projection.go | 3 + internal/query/v2-default.json | 1557 +++++++++++++++++ internal/repository/instance/eventstore.go | 1 + .../instance/hosted_login_translation.go | 55 + internal/repository/org/eventstore.go | 1 + .../org/hosted_login_translation.go | 55 + proto/zitadel/settings/v2/settings.proto | 2 +- .../settings/v2/settings_service.proto | 137 ++ 23 files changed, 3613 insertions(+), 527 deletions(-) create mode 100644 internal/api/grpc/settings/v2/integration_test/query_test.go create mode 100644 internal/api/grpc/settings/v2/query.go create mode 100644 internal/command/hosted_login_translation.go create mode 100644 internal/command/hosted_login_translation_model.go create mode 100644 internal/command/hosted_login_translation_test.go create mode 100644 internal/query/hosted_login_translation.go create mode 100644 internal/query/hosted_login_translation_test.go create mode 100644 internal/query/projection/hosted_login_translation.go create mode 100644 internal/query/v2-default.json create mode 100644 internal/repository/instance/hosted_login_translation.go create mode 100644 internal/repository/org/hosted_login_translation.go diff --git a/go.mod b/go.mod index 15b3d2b391..9d02050b48 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.24.1 require ( cloud.google.com/go/profiler v0.4.2 cloud.google.com/go/storage v1.54.0 + dario.cat/mergo v1.0.2 github.com/BurntSushi/toml v1.5.0 github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.27.0 @@ -65,6 +66,7 @@ require ( github.com/riverqueue/river v0.22.0 github.com/riverqueue/river/riverdriver v0.22.0 github.com/riverqueue/river/rivertype v0.22.0 + github.com/riverqueue/rivercontrib/otelriver v0.5.0 github.com/rs/cors v1.11.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/shopspring/decimal v1.3.1 @@ -146,7 +148,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/riverqueue/river/rivershared v0.22.0 // indirect - github.com/riverqueue/rivercontrib/otelriver v0.5.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect diff --git a/go.sum b/go.sum index 272ce655a3..e2ab9768a6 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ cloud.google.com/go/storage v1.54.0 h1:Du3XEyliAiftfyW0bwfdppm2MMLdpVAfiIg4T2nAI cloud.google.com/go/storage v1.54.0/go.mod h1:hIi9Boe8cHxTyaeqh7KMMwKg088VblFK46C2x/BWaZE= cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= diff --git a/internal/api/authz/context_mock.go b/internal/api/authz/context_mock.go index 6891030bd3..d26b371bc6 100644 --- a/internal/api/authz/context_mock.go +++ b/internal/api/authz/context_mock.go @@ -1,10 +1,28 @@ package authz -import "context" +import ( + "context" -func NewMockContext(instanceID, orgID, userID string) context.Context { + "golang.org/x/text/language" +) + +type MockContextInstanceOpts func(i *instance) + +func WithMockDefaultLanguage(lang language.Tag) MockContextInstanceOpts { + return func(i *instance) { + i.defaultLanguage = lang + } +} + +func NewMockContext(instanceID, orgID, userID string, opts ...MockContextInstanceOpts) context.Context { ctx := context.WithValue(context.Background(), dataKey, CtxData{UserID: userID, OrgID: orgID}) - return context.WithValue(ctx, instanceKey, &instance{id: instanceID}) + + i := &instance{id: instanceID} + for _, o := range opts { + o(i) + } + + return context.WithValue(ctx, instanceKey, i) } func NewMockContextWithAgent(instanceID, orgID, userID, agentID string) context.Context { diff --git a/internal/api/grpc/settings/v2/integration_test/query_test.go b/internal/api/grpc/settings/v2/integration_test/query_test.go new file mode 100644 index 0000000000..c3bf54e992 --- /dev/null +++ b/internal/api/grpc/settings/v2/integration_test/query_test.go @@ -0,0 +1,432 @@ +//go:build integration + +package settings_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "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/integration" + "github.com/zitadel/zitadel/pkg/grpc/idp" + idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +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: Instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + 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) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.ctx, time.Minute) + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + resp, err := Client.GetSecuritySettings(tt.ctx, &settings.GetSecuritySettingsRequest{}) + if tt.wantErr { + assert.Error(ct, err) + return + } + if !assert.NoError(ct, err) { + return + } + got, want := resp.GetSettings(), tt.want.GetSettings() + assert.Equal(ct, want.GetEmbeddedIframe().GetEnabled(), got.GetEmbeddedIframe().GetEnabled(), "enable iframe embedding") + assert.Equal(ct, want.GetEmbeddedIframe().GetAllowedOrigins(), got.GetEmbeddedIframe().GetAllowedOrigins(), "allowed origins") + assert.Equal(ct, want.GetEnableImpersonation(), got.GetEnableImpersonation(), "enable impersonation") + }, retryDuration, tick) + }) + } +} + +func idpResponse(id, name string, linking, creation, autoCreation, autoUpdate bool, autoLinking idp_pb.AutoLinkingOption) *settings.IdentityProvider { + return &settings.IdentityProvider{ + Id: id, + Name: name, + Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OAUTH, + Options: &idp_pb.Options{ + IsLinkingAllowed: linking, + IsCreationAllowed: creation, + IsAutoCreation: autoCreation, + IsAutoUpdate: autoUpdate, + AutoLinking: autoLinking, + }, + } +} + +func TestServer_GetActiveIdentityProviders(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + instance.AddGenericOAuthProvider(isolatedIAMOwnerCTX, gofakeit.AppName()) // inactive + idpActiveName := gofakeit.AppName() + idpActiveResp := instance.AddGenericOAuthProvider(isolatedIAMOwnerCTX, idpActiveName) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpActiveResp.GetId()) + idpActiveResponse := idpResponse(idpActiveResp.GetId(), idpActiveName, true, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpLinkingDisallowedName := gofakeit.AppName() + idpLinkingDisallowedResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpLinkingDisallowedName, false, true, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpLinkingDisallowedResp.GetId()) + idpLinkingDisallowedResponse := idpResponse(idpLinkingDisallowedResp.GetId(), idpLinkingDisallowedName, false, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpCreationDisallowedName := gofakeit.AppName() + idpCreationDisallowedResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpCreationDisallowedName, true, false, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpCreationDisallowedResp.GetId()) + idpCreationDisallowedResponse := idpResponse(idpCreationDisallowedResp.GetId(), idpCreationDisallowedName, true, false, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpNoAutoCreationName := gofakeit.AppName() + idpNoAutoCreationResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpNoAutoCreationName, true, true, false, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpNoAutoCreationResp.GetId()) + idpNoAutoCreationResponse := idpResponse(idpNoAutoCreationResp.GetId(), idpNoAutoCreationName, true, true, false, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpNoAutoLinkingName := gofakeit.AppName() + idpNoAutoLinkingResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpNoAutoLinkingName, true, true, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpNoAutoLinkingResp.GetId()) + idpNoAutoLinkingResponse := idpResponse(idpNoAutoLinkingResp.GetId(), idpNoAutoLinkingName, true, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED) + + type args struct { + ctx context.Context + req *settings.GetActiveIdentityProvidersRequest + } + tests := []struct { + name string + args args + want *settings.GetActiveIdentityProvidersResponse + wantErr bool + }{ + { + name: "permission error", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + req: &settings.GetActiveIdentityProvidersRequest{}, + }, + wantErr: true, + }, + { + name: "success, all", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{}, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 5, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpCreationDisallowedResponse, + idpNoAutoCreationResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, exclude linking disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + LinkingAllowed: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpCreationDisallowedResponse, + idpNoAutoCreationResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, only linking disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + LinkingAllowed: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpLinkingDisallowedResponse, + }, + }, + }, + { + name: "success, exclude creation disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + CreationAllowed: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpNoAutoCreationResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, only creation disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + CreationAllowed: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpCreationDisallowedResponse, + }, + }, + }, + { + name: "success, auto creation", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoCreation: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpCreationDisallowedResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, no auto creation", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoCreation: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpNoAutoCreationResponse, + }, + }, + }, + { + name: "success, auto linking", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoLinking: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpCreationDisallowedResponse, + idpNoAutoCreationResponse, + }, + }, + }, + { + name: "success, no auto linking", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoLinking: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, exclude all", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + LinkingAllowed: gu.Ptr(true), + CreationAllowed: gu.Ptr(true), + AutoCreation: gu.Ptr(true), + AutoLinking: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + got, err := instance.Client.SettingsV2.GetActiveIdentityProviders(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(ct, err) + return + } + if !assert.NoError(ct, err) { + return + } + for i, result := range tt.want.GetIdentityProviders() { + assert.EqualExportedValues(ct, result, got.GetIdentityProviders()[i]) + } + integration.AssertListDetails(ct, tt.want, got) + }, retryDuration, tick) + }) + } +} + +func TestServer_GetHostedLoginTranslation(t *testing.T) { + // Given + translations := map[string]any{"loginTitle": gofakeit.Slogan()} + + protoTranslations, err := structpb.NewStruct(translations) + require.NoError(t, err) + + setupRequest := &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: Instance.DefaultOrg.GetId(), + }, + Translations: protoTranslations, + Locale: gofakeit.LanguageBCP(), + } + savedTranslation, err := Client.SetHostedLoginTranslation(AdminCTX, setupRequest) + require.NoError(t, err) + + tt := []struct { + testName string + inputCtx context.Context + inputRequest *settings.GetHostedLoginTranslationRequest + + expectedErrorCode codes.Code + expectedErrorMsg string + expectedResponse *settings.GetHostedLoginTranslationResponse + }{ + { + testName: "when unauthN context should return unauthN error", + inputCtx: CTX, + inputRequest: &settings.GetHostedLoginTranslationRequest{Locale: "en-US"}, + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputCtx: OrgOwnerCtx, + inputRequest: &settings.GetHostedLoginTranslationRequest{Locale: "en-US"}, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when authZ request should save to db and return etag", + inputCtx: AdminCTX, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Level: &settings.GetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: Instance.DefaultOrg.GetId(), + }, + Locale: setupRequest.GetLocale(), + }, + expectedResponse: &settings.GetHostedLoginTranslationResponse{ + Etag: savedTranslation.GetEtag(), + Translations: protoTranslations, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // When + res, err := Client.GetHostedLoginTranslation(tc.inputCtx, tc.inputRequest) + + // Then + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + require.NoError(t, err) + assert.NotEmpty(t, res.GetEtag()) + assert.NotEmpty(t, res.GetTranslations().GetFields()) + } + }) + } +} diff --git a/internal/api/grpc/settings/v2/integration_test/server_test.go b/internal/api/grpc/settings/v2/integration_test/server_test.go index d57e2a7694..c5c851c310 100644 --- a/internal/api/grpc/settings/v2/integration_test/server_test.go +++ b/internal/api/grpc/settings/v2/integration_test/server_test.go @@ -13,9 +13,9 @@ import ( ) var ( - CTX, AdminCTX context.Context - Instance *integration.Instance - Client settings.SettingsServiceClient + CTX, AdminCTX, UserTypeLoginCtx, OrgOwnerCtx context.Context + Instance *integration.Instance + Client settings.SettingsServiceClient ) func TestMain(m *testing.M) { @@ -27,6 +27,9 @@ func TestMain(m *testing.M) { CTX = ctx AdminCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + UserTypeLoginCtx = Instance.WithAuthorization(ctx, integration.UserTypeLogin) + OrgOwnerCtx = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + Client = Instance.Client.SettingsV2 return m.Run() }()) diff --git a/internal/api/grpc/settings/v2/integration_test/settings_test.go b/internal/api/grpc/settings/v2/integration_test/settings_test.go index 3430eae5f8..7d1e4b0239 100644 --- a/internal/api/grpc/settings/v2/integration_test/settings_test.go +++ b/internal/api/grpc/settings/v2/integration_test/settings_test.go @@ -4,78 +4,23 @@ package settings_test import ( "context" + "crypto/md5" + "encoding/hex" + "fmt" "testing" - "time" - "github.com/brianvoe/gofakeit/v6" - "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/integration" - "github.com/zitadel/zitadel/pkg/grpc/idp" - idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) -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: Instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), - 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) { - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.ctx, time.Minute) - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - resp, err := Client.GetSecuritySettings(tt.ctx, &settings.GetSecuritySettingsRequest{}) - if tt.wantErr { - assert.Error(ct, err) - return - } - if !assert.NoError(ct, err) { - return - } - got, want := resp.GetSettings(), tt.want.GetSettings() - assert.Equal(ct, want.GetEmbeddedIframe().GetEnabled(), got.GetEmbeddedIframe().GetEnabled(), "enable iframe embedding") - assert.Equal(ct, want.GetEmbeddedIframe().GetAllowedOrigins(), got.GetEmbeddedIframe().GetAllowedOrigins(), "allowed origins") - assert.Equal(ct, want.GetEnableImpersonation(), got.GetEnableImpersonation(), "enable impersonation") - }, retryDuration, tick) - }) - } -} - func TestServer_SetSecuritySettings(t *testing.T) { type args struct { ctx context.Context @@ -183,280 +128,64 @@ func TestServer_SetSecuritySettings(t *testing.T) { } } -func idpResponse(id, name string, linking, creation, autoCreation, autoUpdate bool, autoLinking idp_pb.AutoLinkingOption) *settings.IdentityProvider { - return &settings.IdentityProvider{ - Id: id, - Name: name, - Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OAUTH, - Options: &idp_pb.Options{ - IsLinkingAllowed: linking, - IsCreationAllowed: creation, - IsAutoCreation: autoCreation, - IsAutoUpdate: autoUpdate, - AutoLinking: autoLinking, - }, - } -} +func TestSetHostedLoginTranslation(t *testing.T) { + translations := map[string]any{"loginTitle": "Welcome to our service"} -func TestServer_GetActiveIdentityProviders(t *testing.T) { - instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + protoTranslations, err := structpb.NewStruct(translations) + require.Nil(t, err) - instance.AddGenericOAuthProvider(isolatedIAMOwnerCTX, gofakeit.AppName()) // inactive - idpActiveName := gofakeit.AppName() - idpActiveResp := instance.AddGenericOAuthProvider(isolatedIAMOwnerCTX, idpActiveName) - instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpActiveResp.GetId()) - idpActiveResponse := idpResponse(idpActiveResp.GetId(), idpActiveName, true, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - idpLinkingDisallowedName := gofakeit.AppName() - idpLinkingDisallowedResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpLinkingDisallowedName, false, true, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpLinkingDisallowedResp.GetId()) - idpLinkingDisallowedResponse := idpResponse(idpLinkingDisallowedResp.GetId(), idpLinkingDisallowedName, false, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - idpCreationDisallowedName := gofakeit.AppName() - idpCreationDisallowedResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpCreationDisallowedName, true, false, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpCreationDisallowedResp.GetId()) - idpCreationDisallowedResponse := idpResponse(idpCreationDisallowedResp.GetId(), idpCreationDisallowedName, true, false, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - idpNoAutoCreationName := gofakeit.AppName() - idpNoAutoCreationResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpNoAutoCreationName, true, true, false, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpNoAutoCreationResp.GetId()) - idpNoAutoCreationResponse := idpResponse(idpNoAutoCreationResp.GetId(), idpNoAutoCreationName, true, true, false, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - idpNoAutoLinkingName := gofakeit.AppName() - idpNoAutoLinkingResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpNoAutoLinkingName, true, true, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED) - instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpNoAutoLinkingResp.GetId()) - idpNoAutoLinkingResponse := idpResponse(idpNoAutoLinkingResp.GetId(), idpNoAutoLinkingName, true, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED) + hash := md5.Sum(fmt.Append(nil, translations)) - type args struct { - ctx context.Context - req *settings.GetActiveIdentityProvidersRequest - } - tests := []struct { - name string - args args - want *settings.GetActiveIdentityProvidersResponse - wantErr bool + tt := []struct { + testName string + inputCtx context.Context + inputRequest *settings.SetHostedLoginTranslationRequest + + expectedErrorCode codes.Code + expectedErrorMsg string + expectedResponse *settings.SetHostedLoginTranslationResponse }{ { - name: "permission error", - args: args{ - ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), - req: &settings.GetActiveIdentityProvidersRequest{}, - }, - wantErr: true, + testName: "when unauthN context should return unauthN error", + inputCtx: CTX, + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", }, { - name: "success, all", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{}, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 5, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - idpLinkingDisallowedResponse, - idpCreationDisallowedResponse, - idpNoAutoCreationResponse, - idpNoAutoLinkingResponse, - }, - }, + testName: "when unauthZ context should return unauthZ error", + inputCtx: UserTypeLoginCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", }, { - name: "success, exclude linking disallowed", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - LinkingAllowed: gu.Ptr(true), + testName: "when authZ request should save to db and return etag", + inputCtx: AdminCTX, + inputRequest: &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: Instance.DefaultOrg.GetId(), }, + Translations: protoTranslations, + Locale: "en-US", }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 4, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - idpCreationDisallowedResponse, - idpNoAutoCreationResponse, - idpNoAutoLinkingResponse, - }, - }, - }, - { - name: "success, only linking disallowed", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - LinkingAllowed: gu.Ptr(false), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpLinkingDisallowedResponse, - }, - }, - }, - { - name: "success, exclude creation disallowed", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - CreationAllowed: gu.Ptr(true), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 4, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - idpLinkingDisallowedResponse, - idpNoAutoCreationResponse, - idpNoAutoLinkingResponse, - }, - }, - }, - { - name: "success, only creation disallowed", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - CreationAllowed: gu.Ptr(false), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpCreationDisallowedResponse, - }, - }, - }, - { - name: "success, auto creation", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - AutoCreation: gu.Ptr(true), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 4, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - idpLinkingDisallowedResponse, - idpCreationDisallowedResponse, - idpNoAutoLinkingResponse, - }, - }, - }, - { - name: "success, no auto creation", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - AutoCreation: gu.Ptr(false), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpNoAutoCreationResponse, - }, - }, - }, - { - name: "success, auto linking", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - AutoLinking: gu.Ptr(true), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 4, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - idpLinkingDisallowedResponse, - idpCreationDisallowedResponse, - idpNoAutoCreationResponse, - }, - }, - }, - { - name: "success, no auto linking", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - AutoLinking: gu.Ptr(false), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpNoAutoLinkingResponse, - }, - }, - }, - { - name: "success, exclude all", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - LinkingAllowed: gu.Ptr(true), - CreationAllowed: gu.Ptr(true), - AutoCreation: gu.Ptr(true), - AutoLinking: gu.Ptr(true), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - }, + expectedResponse: &settings.SetHostedLoginTranslationResponse{ + Etag: hex.EncodeToString(hash[:]), }, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - got, err := instance.Client.SettingsV2.GetActiveIdentityProviders(tt.args.ctx, tt.args.req) - if tt.wantErr { - assert.Error(ct, err) - return - } - if !assert.NoError(ct, err) { - return - } - for i, result := range tt.want.GetIdentityProviders() { - assert.EqualExportedValues(ct, result, got.GetIdentityProviders()[i]) - } - integration.AssertListDetails(ct, tt.want, got) - }, retryDuration, tick) + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // When + res, err := Client.SetHostedLoginTranslation(tc.inputCtx, tc.inputRequest) + + // Then + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + require.NoError(t, err) + assert.Equal(t, tc.expectedResponse.GetEtag(), res.GetEtag()) + } }) } } diff --git a/internal/api/grpc/settings/v2/query.go b/internal/api/grpc/settings/v2/query.go new file mode 100644 index 0000000000..b8994ccb87 --- /dev/null +++ b/internal/api/grpc/settings/v2/query.go @@ -0,0 +1,209 @@ +package settings + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "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/v2" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +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, + CreationDate: timestamppb.New(current.CreationDate), + 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, + CreationDate: timestamppb.New(current.CreationDate), + 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, + CreationDate: timestamppb.New(current.CreationDate), + 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, + CreationDate: timestamppb.New(current.CreationDate), + 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, + CreationDate: timestamppb.New(current.CreationDate), + 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, + CreationDate: timestamppb.New(current.CreationDate), + 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, + CreationDate: timestamppb.New(current.CreationDate), + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) { + queries, err := activeIdentityProvidersToQuery(req) + if err != nil { + return nil, err + } + + links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{Queries: queries}, false) + if err != nil { + return nil, err + } + + return &settings.GetActiveIdentityProvidersResponse{ + Details: object.ToListDetails(links.SearchResponse), + IdentityProviders: identityProvidersToPb(links.Links), + }, nil +} + +func activeIdentityProvidersToQuery(req *settings.GetActiveIdentityProvidersRequest) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, 0, 4) + if req.CreationAllowed != nil { + creationQuery, err := query.NewIDPTemplateIsCreationAllowedSearchQuery(*req.CreationAllowed) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + if req.LinkingAllowed != nil { + creationQuery, err := query.NewIDPTemplateIsLinkingAllowedSearchQuery(*req.LinkingAllowed) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + if req.AutoCreation != nil { + creationQuery, err := query.NewIDPTemplateIsAutoCreationSearchQuery(*req.AutoCreation) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + if req.AutoLinking != nil { + compare := query.NumberEquals + if *req.AutoLinking { + compare = query.NumberNotEquals + } + creationQuery, err := query.NewIDPTemplateAutoLinkingSearchQuery(0, compare) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + return q, 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) GetHostedLoginTranslation(ctx context.Context, req *settings.GetHostedLoginTranslationRequest) (*settings.GetHostedLoginTranslationResponse, error) { + translation, err := s.query.GetHostedLoginTranslation(ctx, req) + if err != nil { + return nil, err + } + + return translation, nil +} diff --git a/internal/api/grpc/settings/v2/settings.go b/internal/api/grpc/settings/v2/settings.go index 77874bf970..09ee6b27c8 100644 --- a/internal/api/grpc/settings/v2/settings.go +++ b/internal/api/grpc/settings/v2/settings.go @@ -3,202 +3,10 @@ package settings import ( "context" - "google.golang.org/protobuf/types/known/timestamppb" - - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" - "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/v2" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) -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, - CreationDate: timestamppb.New(current.CreationDate), - 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, - CreationDate: timestamppb.New(current.CreationDate), - 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, - CreationDate: timestamppb.New(current.CreationDate), - 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, - CreationDate: timestamppb.New(current.CreationDate), - 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, - CreationDate: timestamppb.New(current.CreationDate), - 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, - CreationDate: timestamppb.New(current.CreationDate), - 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, - CreationDate: timestamppb.New(current.CreationDate), - ChangeDate: timestamppb.New(current.ChangeDate), - ResourceOwner: current.ResourceOwner, - }, - }, nil -} - -func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) { - queries, err := activeIdentityProvidersToQuery(req) - if err != nil { - return nil, err - } - - links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{Queries: queries}, false) - if err != nil { - return nil, err - } - - return &settings.GetActiveIdentityProvidersResponse{ - Details: object.ToListDetails(links.SearchResponse), - IdentityProviders: identityProvidersToPb(links.Links), - }, nil -} - -func activeIdentityProvidersToQuery(req *settings.GetActiveIdentityProvidersRequest) (_ []query.SearchQuery, err error) { - q := make([]query.SearchQuery, 0, 4) - if req.CreationAllowed != nil { - creationQuery, err := query.NewIDPTemplateIsCreationAllowedSearchQuery(*req.CreationAllowed) - if err != nil { - return nil, err - } - q = append(q, creationQuery) - } - if req.LinkingAllowed != nil { - creationQuery, err := query.NewIDPTemplateIsLinkingAllowedSearchQuery(*req.LinkingAllowed) - if err != nil { - return nil, err - } - q = append(q, creationQuery) - } - if req.AutoCreation != nil { - creationQuery, err := query.NewIDPTemplateIsAutoCreationSearchQuery(*req.AutoCreation) - if err != nil { - return nil, err - } - q = append(q, creationQuery) - } - if req.AutoLinking != nil { - compare := query.NumberEquals - if *req.AutoLinking { - compare = query.NumberNotEquals - } - creationQuery, err := query.NewIDPTemplateAutoLinkingSearchQuery(0, compare) - if err != nil { - return nil, err - } - q = append(q, creationQuery) - } - return q, 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 { @@ -208,3 +16,12 @@ func (s *Server) SetSecuritySettings(ctx context.Context, req *settings.SetSecur Details: object.DomainToDetailsPb(details), }, nil } + +func (s *Server) SetHostedLoginTranslation(ctx context.Context, req *settings.SetHostedLoginTranslationRequest) (*settings.SetHostedLoginTranslationResponse, error) { + res, err := s.command.SetHostedLoginTranslation(ctx, req) + if err != nil { + return nil, err + } + + return res, nil +} diff --git a/internal/command/hosted_login_translation.go b/internal/command/hosted_login_translation.go new file mode 100644 index 0000000000..024ab6bdad --- /dev/null +++ b/internal/command/hosted_login_translation.go @@ -0,0 +1,73 @@ +package command + +import ( + "context" + "crypto/md5" + "encoding/hex" + "fmt" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +func (c *Commands) SetHostedLoginTranslation(ctx context.Context, req *settings.SetHostedLoginTranslationRequest) (res *settings.SetHostedLoginTranslationResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + var agg eventstore.Aggregate + switch t := req.GetLevel().(type) { + case *settings.SetHostedLoginTranslationRequest_Instance: + agg = instance.NewAggregate(authz.GetInstance(ctx).InstanceID()).Aggregate + case *settings.SetHostedLoginTranslationRequest_OrganizationId: + agg = org.NewAggregate(t.OrganizationId).Aggregate + default: + return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-YB6Sri", "Errors.Arguments.Level.Invalid") + } + + lang, err := language.Parse(req.GetLocale()) + if err != nil || lang.IsRoot() { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-xmjATA", "Errors.Arguments.Locale.Invalid") + } + + commands, wm, err := c.setTranslationEvents(ctx, agg, lang, req.GetTranslations().AsMap()) + if err != nil { + return nil, err + } + + pushedEvents, err := c.eventstore.Push(ctx, commands...) + if err != nil { + return nil, zerrors.ThrowInternal(err, "COMMA-i8nqFl", "Errors.Internal") + } + + err = AppendAndReduce(wm, pushedEvents...) + if err != nil { + return nil, err + } + + etag := md5.Sum(fmt.Append(nil, wm.Translation)) + return &settings.SetHostedLoginTranslationResponse{ + Etag: hex.EncodeToString(etag[:]), + }, nil +} + +func (c *Commands) setTranslationEvents(ctx context.Context, agg eventstore.Aggregate, lang language.Tag, translations map[string]any) ([]eventstore.Command, *HostedLoginTranslationWriteModel, error) { + wm := NewHostedLoginTranslationWriteModel(agg.ID) + events := []eventstore.Command{} + switch agg.Type { + case instance.AggregateType: + events = append(events, instance.NewHostedLoginTranslationSetEvent(ctx, &agg, translations, lang)) + case org.AggregateType: + events = append(events, org.NewHostedLoginTranslationSetEvent(ctx, &agg, translations, lang)) + default: + return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMA-0aw7In", "Errors.Arguments.LevelType.Invalid") + } + + return events, wm, nil +} diff --git a/internal/command/hosted_login_translation_model.go b/internal/command/hosted_login_translation_model.go new file mode 100644 index 0000000000..16bc42c541 --- /dev/null +++ b/internal/command/hosted_login_translation_model.go @@ -0,0 +1,45 @@ +package command + +import ( + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" +) + +type HostedLoginTranslationWriteModel struct { + eventstore.WriteModel + Language language.Tag + Translation map[string]any + Level string + LevelID string +} + +func NewHostedLoginTranslationWriteModel(resourceID string) *HostedLoginTranslationWriteModel { + return &HostedLoginTranslationWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: resourceID, + ResourceOwner: resourceID, + }, + } +} + +func (wm *HostedLoginTranslationWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *org.HostedLoginTranslationSetEvent: + wm.Language = e.Language + wm.Translation = e.Translation + wm.Level = e.Level + wm.LevelID = e.Aggregate().ID + case *instance.HostedLoginTranslationSetEvent: + wm.Language = e.Language + wm.Translation = e.Translation + wm.Level = e.Level + wm.LevelID = e.Aggregate().ID + } + } + + return wm.WriteModel.Reduce() +} diff --git a/internal/command/hosted_login_translation_test.go b/internal/command/hosted_login_translation_test.go new file mode 100644 index 0000000000..a5f0941711 --- /dev/null +++ b/internal/command/hosted_login_translation_test.go @@ -0,0 +1,211 @@ +package command + +import ( + "context" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/service" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +func TestSetTranslationEvents(t *testing.T) { + t.Parallel() + + testCtx := authz.SetCtxData(context.Background(), authz.CtxData{UserID: "test-user"}) + testCtx = service.WithService(testCtx, "test-service") + + tt := []struct { + testName string + + inputAggregate eventstore.Aggregate + inputLanguage language.Tag + inputTranslations map[string]any + + expectedCommands []eventstore.Command + expectedWriteModel *HostedLoginTranslationWriteModel + expectedError error + }{ + { + testName: "when aggregate type is instance should return matching write model and instance.hosted_login_translation_set event", + inputAggregate: eventstore.Aggregate{ID: "123", Type: instance.AggregateType}, + inputLanguage: language.MustParse("en-US"), + inputTranslations: map[string]any{"test": "translation"}, + expectedCommands: []eventstore.Command{ + instance.NewHostedLoginTranslationSetEvent(testCtx, &eventstore.Aggregate{ID: "123", Type: instance.AggregateType}, map[string]any{"test": "translation"}, language.MustParse("en-US")), + }, + expectedWriteModel: &HostedLoginTranslationWriteModel{ + WriteModel: eventstore.WriteModel{AggregateID: "123", ResourceOwner: "123"}, + }, + }, + { + testName: "when aggregate type is org should return matching write model and org.hosted_login_translation_set event", + inputAggregate: eventstore.Aggregate{ID: "123", Type: org.AggregateType}, + inputLanguage: language.MustParse("en-GB"), + inputTranslations: map[string]any{"test": "translation"}, + expectedCommands: []eventstore.Command{ + org.NewHostedLoginTranslationSetEvent(testCtx, &eventstore.Aggregate{ID: "123", Type: org.AggregateType}, map[string]any{"test": "translation"}, language.MustParse("en-GB")), + }, + expectedWriteModel: &HostedLoginTranslationWriteModel{ + WriteModel: eventstore.WriteModel{AggregateID: "123", ResourceOwner: "123"}, + }, + }, + { + testName: "when aggregate type is neither org nor instance should return invalid argument error", + inputAggregate: eventstore.Aggregate{ID: "123"}, + inputLanguage: language.MustParse("en-US"), + inputTranslations: map[string]any{"test": "translation"}, + expectedError: zerrors.ThrowInvalidArgument(nil, "COMMA-0aw7In", "Errors.Arguments.LevelType.Invalid"), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // Given + c := Commands{} + + // When + events, writeModel, err := c.setTranslationEvents(testCtx, tc.inputAggregate, tc.inputLanguage, tc.inputTranslations) + + // Verify + require.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedWriteModel, writeModel) + + require.Len(t, events, len(tc.expectedCommands)) + assert.ElementsMatch(t, tc.expectedCommands, events) + }) + } +} + +func TestSetHostedLoginTranslation(t *testing.T) { + t.Parallel() + + testCtx := authz.SetCtxData(context.Background(), authz.CtxData{UserID: "test-user"}) + testCtx = service.WithService(testCtx, "test-service") + testCtx = authz.WithInstanceID(testCtx, "instance-id") + + testTranslation := map[string]any{"test": "translation", "translation": "2"} + protoTranslation, err := structpb.NewStruct(testTranslation) + require.NoError(t, err) + + hashTestTranslation := md5.Sum(fmt.Append(nil, testTranslation)) + require.NotEmpty(t, hashTestTranslation) + + tt := []struct { + testName string + + mockPush func(*testing.T) *eventstore.Eventstore + + inputReq *settings.SetHostedLoginTranslationRequest + + expectedError error + expectedResult *settings.SetHostedLoginTranslationResponse + }{ + { + testName: "when locale is malformed should return invalid argument error", + mockPush: func(t *testing.T) *eventstore.Eventstore { return &eventstore.Eventstore{} }, + inputReq: &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_Instance{}, + Locale: "123", + }, + + expectedError: zerrors.ThrowInvalidArgument(nil, "COMMA-xmjATA", "Errors.Arguments.Locale.Invalid"), + }, + { + testName: "when locale is unknown should return invalid argument error", + mockPush: func(t *testing.T) *eventstore.Eventstore { return &eventstore.Eventstore{} }, + inputReq: &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_Instance{}, + Locale: "root", + }, + + expectedError: zerrors.ThrowInvalidArgument(nil, "COMMA-xmjATA", "Errors.Arguments.Locale.Invalid"), + }, + { + testName: "when event pushing fails should return internal error", + + mockPush: expectEventstore(expectPushFailed( + errors.New("mock push failed"), + instance.NewHostedLoginTranslationSetEvent( + testCtx, &eventstore.Aggregate{ + ID: "instance-id", + Type: instance.AggregateType, + ResourceOwner: "instance-id", + InstanceID: "instance-id", + Version: instance.AggregateVersion, + }, + testTranslation, + language.MustParse("it-CH"), + ), + )), + + inputReq: &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_Instance{}, + Locale: "it-CH", + Translations: protoTranslation, + }, + + expectedError: zerrors.ThrowInternal(errors.New("mock push failed"), "COMMA-i8nqFl", "Errors.Internal"), + }, + { + testName: "when request is valid should return expected response", + + mockPush: expectEventstore(expectPush( + org.NewHostedLoginTranslationSetEvent( + testCtx, &eventstore.Aggregate{ + ID: "org-id", + Type: org.AggregateType, + ResourceOwner: "org-id", + InstanceID: "", + Version: org.AggregateVersion, + }, + testTranslation, + language.MustParse("it-CH"), + ), + )), + + inputReq: &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_OrganizationId{OrganizationId: "org-id"}, + Locale: "it-CH", + Translations: protoTranslation, + }, + + expectedResult: &settings.SetHostedLoginTranslationResponse{ + Etag: hex.EncodeToString(hashTestTranslation[:]), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // Given + c := Commands{ + eventstore: tc.mockPush(t), + } + + // When + res, err := c.SetHostedLoginTranslation(testCtx, tc.inputReq) + + // Verify + require.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResult, res) + }) + } +} diff --git a/internal/database/mock/sql_mock.go b/internal/database/mock/sql_mock.go index b8030b269f..cd30cd9cf0 100644 --- a/internal/database/mock/sql_mock.go +++ b/internal/database/mock/sql_mock.go @@ -14,9 +14,9 @@ type SQLMock struct { mock sqlmock.Sqlmock } -type expectation func(m sqlmock.Sqlmock) +type Expectation func(m sqlmock.Sqlmock) -func NewSQLMock(t *testing.T, expectations ...expectation) *SQLMock { +func NewSQLMock(t *testing.T, expectations ...Expectation) *SQLMock { db, mock, err := sqlmock.New( sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual), sqlmock.ValueConverterOption(new(TypeConverter)), @@ -45,7 +45,7 @@ func (m *SQLMock) Assert(t *testing.T) { m.DB.Close() } -func ExpectBegin(err error) expectation { +func ExpectBegin(err error) Expectation { return func(m sqlmock.Sqlmock) { e := m.ExpectBegin() if err != nil { @@ -54,7 +54,7 @@ func ExpectBegin(err error) expectation { } } -func ExpectCommit(err error) expectation { +func ExpectCommit(err error) Expectation { return func(m sqlmock.Sqlmock) { e := m.ExpectCommit() if err != nil { @@ -89,7 +89,7 @@ func WithExecRowsAffected(affected driver.RowsAffected) ExecOpt { } } -func ExcpectExec(stmt string, opts ...ExecOpt) expectation { +func ExcpectExec(stmt string, opts ...ExecOpt) Expectation { return func(m sqlmock.Sqlmock) { e := m.ExpectExec(stmt) for _, opt := range opts { @@ -122,7 +122,7 @@ func WithQueryResult(columns []string, rows [][]driver.Value) QueryOpt { } } -func ExpectQuery(stmt string, opts ...QueryOpt) expectation { +func ExpectQuery(stmt string, opts ...QueryOpt) Expectation { return func(m sqlmock.Sqlmock) { e := m.ExpectQuery(stmt) for _, opt := range opts { diff --git a/internal/query/hosted_login_translation.go b/internal/query/hosted_login_translation.go new file mode 100644 index 0000000000..82193d2069 --- /dev/null +++ b/internal/query/hosted_login_translation.go @@ -0,0 +1,256 @@ +package query + +import ( + "context" + "crypto/md5" + "database/sql" + _ "embed" + "encoding/hex" + "encoding/json" + "fmt" + "time" + + "dario.cat/mergo" + sq "github.com/Masterminds/squirrel" + "github.com/zitadel/logging" + "golang.org/x/text/language" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/v2/org" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +var ( + //go:embed v2-default.json + defaultLoginTranslations []byte + + defaultSystemTranslations map[language.Tag]map[string]any + + hostedLoginTranslationTable = table{ + name: projection.HostedLoginTranslationTable, + instanceIDCol: projection.HostedLoginTranslationInstanceIDCol, + } + + hostedLoginTranslationColInstanceID = Column{ + name: projection.HostedLoginTranslationInstanceIDCol, + table: hostedLoginTranslationTable, + } + hostedLoginTranslationColResourceOwner = Column{ + name: projection.HostedLoginTranslationAggregateIDCol, + table: hostedLoginTranslationTable, + } + hostedLoginTranslationColResourceOwnerType = Column{ + name: projection.HostedLoginTranslationAggregateTypeCol, + table: hostedLoginTranslationTable, + } + hostedLoginTranslationColLocale = Column{ + name: projection.HostedLoginTranslationLocaleCol, + table: hostedLoginTranslationTable, + } + hostedLoginTranslationColFile = Column{ + name: projection.HostedLoginTranslationFileCol, + table: hostedLoginTranslationTable, + } + hostedLoginTranslationColEtag = Column{ + name: projection.HostedLoginTranslationEtagCol, + table: hostedLoginTranslationTable, + } +) + +func init() { + err := json.Unmarshal(defaultLoginTranslations, &defaultSystemTranslations) + if err != nil { + panic(err) + } +} + +type HostedLoginTranslations struct { + SearchResponse + HostedLoginTranslations []*HostedLoginTranslation +} + +type HostedLoginTranslation struct { + AggregateID string + Sequence uint64 + CreationDate time.Time + ChangeDate time.Time + + Locale string + File map[string]any + LevelType string + LevelID string + Etag string +} + +func (q *Queries) GetHostedLoginTranslation(ctx context.Context, req *settings.GetHostedLoginTranslationRequest) (res *settings.GetHostedLoginTranslationResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + inst := authz.GetInstance(ctx) + defaultInstLang := inst.DefaultLanguage() + + lang, err := language.BCP47.Parse(req.GetLocale()) + if err != nil || lang.IsRoot() { + return nil, zerrors.ThrowInvalidArgument(nil, "QUERY-rZLAGi", "Errors.Arguments.Locale.Invalid") + } + parentLang := lang.Parent() + if parentLang.IsRoot() { + parentLang = lang + } + + sysTranslation, systemEtag, err := getSystemTranslation(parentLang, defaultInstLang) + if err != nil { + return nil, err + } + + var levelID, resourceOwner string + switch t := req.GetLevel().(type) { + case *settings.GetHostedLoginTranslationRequest_System: + return getTranslationOutputMessage(sysTranslation, systemEtag) + case *settings.GetHostedLoginTranslationRequest_Instance: + levelID = authz.GetInstance(ctx).InstanceID() + resourceOwner = instance.AggregateType + case *settings.GetHostedLoginTranslationRequest_OrganizationId: + levelID = t.OrganizationId + resourceOwner = org.AggregateType + default: + return nil, zerrors.ThrowInvalidArgument(nil, "QUERY-YB6Sri", "Errors.Arguments.Level.Invalid") + } + + stmt, scan := prepareHostedLoginTranslationQuery() + + langORBaseLang := sq.Or{ + sq.Eq{hostedLoginTranslationColLocale.identifier(): lang.String()}, + sq.Eq{hostedLoginTranslationColLocale.identifier(): parentLang.String()}, + } + eq := sq.Eq{ + hostedLoginTranslationColInstanceID.identifier(): inst.InstanceID(), + hostedLoginTranslationColResourceOwner.identifier(): levelID, + hostedLoginTranslationColResourceOwnerType.identifier(): resourceOwner, + } + + query, args, err := stmt.Where(eq).Where(langORBaseLang).ToSql() + if err != nil { + logging.WithError(err).Error("unable to generate sql statement") + return nil, zerrors.ThrowInternal(err, "QUERY-ZgCMux", "Errors.Query.SQLStatement") + } + + var trs []*HostedLoginTranslation + err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { + trs, err = scan(rows) + return err + }, query, args...) + if err != nil { + logging.WithError(err).Error("failed to query translations") + return nil, zerrors.ThrowInternal(err, "QUERY-6k1zjx", "Errors.Internal") + } + + requestedTranslation, parentTranslation := &HostedLoginTranslation{}, &HostedLoginTranslation{} + for _, tr := range trs { + if tr == nil { + continue + } + + if tr.LevelType == resourceOwner { + requestedTranslation = tr + } else { + parentTranslation = tr + } + } + + if !req.GetIgnoreInheritance() { + + // There is no record for the requested level, set the upper level etag + if requestedTranslation.Etag == "" { + requestedTranslation.Etag = parentTranslation.Etag + } + + // Case where Level == ORGANIZATION -> Check if we have an instance level translation + // If so, merge it with the translations we have + if parentTranslation != nil && parentTranslation.LevelType == instance.AggregateType { + if err := mergo.Merge(&requestedTranslation.File, parentTranslation.File); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-pdgEJd", "Errors.Query.MergeTranslations") + } + } + + // The DB query returned no results, we have to set the system translation etag + if requestedTranslation.Etag == "" { + requestedTranslation.Etag = systemEtag + } + + // Merge the system translations + if err := mergo.Merge(&requestedTranslation.File, sysTranslation); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-HdprNF", "Errors.Query.MergeTranslations") + } + } + + return getTranslationOutputMessage(requestedTranslation.File, requestedTranslation.Etag) +} + +func getSystemTranslation(lang, instanceDefaultLang language.Tag) (map[string]any, string, error) { + translation, ok := defaultSystemTranslations[lang] + if !ok { + translation, ok = defaultSystemTranslations[instanceDefaultLang] + if !ok { + return nil, "", zerrors.ThrowNotFoundf(nil, "QUERY-6gb5QR", "Errors.Query.HostedLoginTranslationNotFound-%s", lang) + } + } + + hash := md5.Sum(fmt.Append(nil, translation)) + + return translation, hex.EncodeToString(hash[:]), nil +} + +func prepareHostedLoginTranslationQuery() (sq.SelectBuilder, func(*sql.Rows) ([]*HostedLoginTranslation, error)) { + return sq.Select( + hostedLoginTranslationColFile.identifier(), + hostedLoginTranslationColResourceOwnerType.identifier(), + hostedLoginTranslationColEtag.identifier(), + ).From(hostedLoginTranslationTable.identifier()). + Limit(2). + PlaceholderFormat(sq.Dollar), + func(r *sql.Rows) ([]*HostedLoginTranslation, error) { + translations := make([]*HostedLoginTranslation, 0, 2) + for r.Next() { + var rawTranslation json.RawMessage + translation := &HostedLoginTranslation{} + err := r.Scan( + &rawTranslation, + &translation.LevelType, + &translation.Etag, + ) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(rawTranslation, &translation.File); err != nil { + return nil, err + } + + translations = append(translations, translation) + } + + if err := r.Close(); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-oc7r7i", "Errors.Query.CloseRows") + } + + return translations, nil + } +} + +func getTranslationOutputMessage(translation map[string]any, etag string) (*settings.GetHostedLoginTranslationResponse, error) { + protoTranslation, err := structpb.NewStruct(translation) + if err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-70ppPp", "Errors.Protobuf.ConvertToStruct") + } + + return &settings.GetHostedLoginTranslationResponse{ + Translations: protoTranslation, + Etag: etag, + }, nil +} diff --git a/internal/query/hosted_login_translation_test.go b/internal/query/hosted_login_translation_test.go new file mode 100644 index 0000000000..0e9f511002 --- /dev/null +++ b/internal/query/hosted_login_translation_test.go @@ -0,0 +1,337 @@ +package query + +import ( + "crypto/md5" + "database/sql" + "database/sql/driver" + "encoding/hex" + "encoding/json" + "fmt" + "maps" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + "google.golang.org/protobuf/runtime/protoimpl" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/database/mock" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +func TestGetSystemTranslation(t *testing.T) { + okTranslation := defaultLoginTranslations + + parsedOKTranslation := map[string]map[string]any{} + require.Nil(t, json.Unmarshal(okTranslation, &parsedOKTranslation)) + + hashOK := md5.Sum(fmt.Append(nil, parsedOKTranslation["de"])) + + tt := []struct { + testName string + + inputLanguage language.Tag + inputInstanceLanguage language.Tag + systemTranslationToSet []byte + + expectedLanguage map[string]any + expectedEtag string + expectedError error + }{ + { + testName: "when neither input language nor system default language have translation should return not found error", + systemTranslationToSet: okTranslation, + inputLanguage: language.MustParse("ro"), + inputInstanceLanguage: language.MustParse("fr"), + + expectedError: zerrors.ThrowNotFoundf(nil, "QUERY-6gb5QR", "Errors.Query.HostedLoginTranslationNotFound-%s", "ro"), + }, + { + testName: "when input language has no translation should fallback onto instance default", + systemTranslationToSet: okTranslation, + inputLanguage: language.MustParse("ro"), + inputInstanceLanguage: language.MustParse("de"), + + expectedLanguage: parsedOKTranslation["de"], + expectedEtag: hex.EncodeToString(hashOK[:]), + }, + { + testName: "when input language has translation should return it", + systemTranslationToSet: okTranslation, + inputLanguage: language.MustParse("de"), + inputInstanceLanguage: language.MustParse("en"), + + expectedLanguage: parsedOKTranslation["de"], + expectedEtag: hex.EncodeToString(hashOK[:]), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Given + defaultLoginTranslations = tc.systemTranslationToSet + + // When + translation, etag, err := getSystemTranslation(tc.inputLanguage, tc.inputInstanceLanguage) + + // Verify + require.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedLanguage, translation) + assert.Equal(t, tc.expectedEtag, etag) + }) + } +} + +func TestGetTranslationOutput(t *testing.T) { + t.Parallel() + + validMap := map[string]any{"loginHeader": "A login header"} + protoMap, err := structpb.NewStruct(validMap) + require.NoError(t, err) + + hash := md5.Sum(fmt.Append(nil, validMap)) + encodedHash := hex.EncodeToString(hash[:]) + + tt := []struct { + testName string + inputTranslation map[string]any + expectedError error + expectedResponse *settings.GetHostedLoginTranslationResponse + }{ + { + testName: "when unparsable map should return internal error", + inputTranslation: map[string]any{"\xc5z": "something"}, + expectedError: zerrors.ThrowInternal(protoimpl.X.NewError("invalid UTF-8 in string: %q", "\xc5z"), "QUERY-70ppPp", "Errors.Protobuf.ConvertToStruct"), + }, + { + testName: "when input translation is valid should return expected response message", + inputTranslation: validMap, + expectedResponse: &settings.GetHostedLoginTranslationResponse{ + Translations: protoMap, + Etag: hex.EncodeToString(hash[:]), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := getTranslationOutputMessage(tc.inputTranslation, encodedHash) + + // Verify + require.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResponse, res) + }) + } +} + +func TestGetHostedLoginTranslation(t *testing.T) { + query := `SELECT projections.hosted_login_translations.file, projections.hosted_login_translations.aggregate_type, projections.hosted_login_translations.etag + FROM projections.hosted_login_translations + WHERE projections.hosted_login_translations.aggregate_id = $1 + AND projections.hosted_login_translations.aggregate_type = $2 + AND projections.hosted_login_translations.instance_id = $3 + AND (projections.hosted_login_translations.locale = $4 OR projections.hosted_login_translations.locale = $5) + LIMIT 2` + okTranslation := defaultLoginTranslations + + parsedOKTranslation := map[string]map[string]any{} + require.NoError(t, json.Unmarshal(okTranslation, &parsedOKTranslation)) + + protoDefaultTranslation, err := structpb.NewStruct(parsedOKTranslation["en"]) + require.Nil(t, err) + + defaultWithDBTranslations := maps.Clone(parsedOKTranslation["en"]) + defaultWithDBTranslations["test"] = "translation" + defaultWithDBTranslations["test2"] = "translation2" + protoDefaultWithDBTranslation, err := structpb.NewStruct(defaultWithDBTranslations) + require.NoError(t, err) + + nilProtoDefaultMap, err := structpb.NewStruct(nil) + require.NoError(t, err) + + hashDefaultTranslations := md5.Sum(fmt.Append(nil, parsedOKTranslation["en"])) + + tt := []struct { + testName string + + defaultInstanceLanguage language.Tag + sqlExpectations []mock.Expectation + + inputRequest *settings.GetHostedLoginTranslationRequest + + expectedError error + expectedResult *settings.GetHostedLoginTranslationResponse + }{ + { + testName: "when input language is invalid should return invalid argument error", + + inputRequest: &settings.GetHostedLoginTranslationRequest{}, + + expectedError: zerrors.ThrowInvalidArgument(nil, "QUERY-rZLAGi", "Errors.Arguments.Locale.Invalid"), + }, + { + testName: "when input language is root should return invalid argument error", + + defaultInstanceLanguage: language.English, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "root", + }, + + expectedError: zerrors.ThrowInvalidArgument(nil, "QUERY-rZLAGi", "Errors.Arguments.Locale.Invalid"), + }, + { + testName: "when no system translation is available should return not found error", + + defaultInstanceLanguage: language.Romanian, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "ro-RO", + }, + + expectedError: zerrors.ThrowNotFoundf(nil, "QUERY-6gb5QR", "Errors.Query.HostedLoginTranslationNotFound-%s", "ro"), + }, + { + testName: "when requesting system translation should return it", + + defaultInstanceLanguage: language.English, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "en-US", + Level: &settings.GetHostedLoginTranslationRequest_System{}, + }, + + expectedResult: &settings.GetHostedLoginTranslationResponse{ + Translations: protoDefaultTranslation, + Etag: hex.EncodeToString(hashDefaultTranslations[:]), + }, + }, + { + testName: "when querying DB fails should return internal error", + + defaultInstanceLanguage: language.English, + sqlExpectations: []mock.Expectation{ + mock.ExpectQuery( + query, + mock.WithQueryArgs("123", "org", "instance-id", "en-US", "en"), + mock.WithQueryErr(sql.ErrConnDone), + ), + }, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "en-US", + Level: &settings.GetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: "123", + }, + }, + + expectedError: zerrors.ThrowInternal(sql.ErrConnDone, "QUERY-6k1zjx", "Errors.Internal"), + }, + { + testName: "when querying DB returns no result should return system translations", + + defaultInstanceLanguage: language.English, + sqlExpectations: []mock.Expectation{ + mock.ExpectQuery( + query, + mock.WithQueryArgs("123", "org", "instance-id", "en-US", "en"), + mock.WithQueryResult( + []string{"file", "aggregate_type", "etag"}, + [][]driver.Value{}, + ), + ), + }, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "en-US", + Level: &settings.GetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: "123", + }, + }, + + expectedResult: &settings.GetHostedLoginTranslationResponse{ + Translations: protoDefaultTranslation, + Etag: hex.EncodeToString(hashDefaultTranslations[:]), + }, + }, + { + testName: "when querying DB returns no result and inheritance disabled should return empty result", + + defaultInstanceLanguage: language.English, + sqlExpectations: []mock.Expectation{ + mock.ExpectQuery( + query, + mock.WithQueryArgs("123", "org", "instance-id", "en-US", "en"), + mock.WithQueryResult( + []string{"file", "aggregate_type", "etag"}, + [][]driver.Value{}, + ), + ), + }, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "en-US", + Level: &settings.GetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: "123", + }, + IgnoreInheritance: true, + }, + + expectedResult: &settings.GetHostedLoginTranslationResponse{ + Etag: "", + Translations: nilProtoDefaultMap, + }, + }, + { + testName: "when querying DB returns records should return merged result", + + defaultInstanceLanguage: language.English, + sqlExpectations: []mock.Expectation{ + mock.ExpectQuery( + query, + mock.WithQueryArgs("123", "org", "instance-id", "en-US", "en"), + mock.WithQueryResult( + []string{"file", "aggregate_type", "etag"}, + [][]driver.Value{ + {[]byte(`{"test": "translation"}`), "org", "etag-org"}, + {[]byte(`{"test2": "translation2"}`), "instance", "etag-instance"}, + }, + ), + ), + }, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "en-US", + Level: &settings.GetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: "123", + }, + }, + + expectedResult: &settings.GetHostedLoginTranslationResponse{ + Etag: "etag-org", + Translations: protoDefaultWithDBTranslation, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Given + db := &database.DB{DB: mock.NewSQLMock(t, tc.sqlExpectations...).DB} + querier := Queries{client: db} + + ctx := authz.NewMockContext("instance-id", "org-id", "user-id", authz.WithMockDefaultLanguage(tc.defaultInstanceLanguage)) + + // When + res, err := querier.GetHostedLoginTranslation(ctx, tc.inputRequest) + + // Verify + require.Equal(t, tc.expectedError, err) + + if tc.expectedError == nil { + assert.Equal(t, tc.expectedResult.GetEtag(), res.GetEtag()) + assert.Equal(t, tc.expectedResult.GetTranslations().GetFields(), res.GetTranslations().GetFields()) + } + }) + } +} diff --git a/internal/query/projection/hosted_login_translation.go b/internal/query/projection/hosted_login_translation.go new file mode 100644 index 0000000000..865d3738b9 --- /dev/null +++ b/internal/query/projection/hosted_login_translation.go @@ -0,0 +1,144 @@ +package projection + +import ( + "context" + "crypto/md5" + "encoding/hex" + "fmt" + + "github.com/zitadel/zitadel/internal/eventstore" + old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + HostedLoginTranslationTable = "projections.hosted_login_translations" + + HostedLoginTranslationInstanceIDCol = "instance_id" + HostedLoginTranslationCreationDateCol = "creation_date" + HostedLoginTranslationChangeDateCol = "change_date" + HostedLoginTranslationAggregateIDCol = "aggregate_id" + HostedLoginTranslationAggregateTypeCol = "aggregate_type" + HostedLoginTranslationSequenceCol = "sequence" + HostedLoginTranslationLocaleCol = "locale" + HostedLoginTranslationFileCol = "file" + HostedLoginTranslationEtagCol = "etag" +) + +type hostedLoginTranslationProjection struct{} + +func newHostedLoginTranslationProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, new(hostedLoginTranslationProjection)) +} + +// Init implements [handler.initializer] +func (p *hostedLoginTranslationProjection) Init() *old_handler.Check { + return handler.NewTableCheck( + handler.NewTable([]*handler.InitColumn{ + handler.NewColumn(HostedLoginTranslationInstanceIDCol, handler.ColumnTypeText), + handler.NewColumn(HostedLoginTranslationCreationDateCol, handler.ColumnTypeTimestamp), + handler.NewColumn(HostedLoginTranslationChangeDateCol, handler.ColumnTypeTimestamp), + handler.NewColumn(HostedLoginTranslationAggregateIDCol, handler.ColumnTypeText), + handler.NewColumn(HostedLoginTranslationAggregateTypeCol, handler.ColumnTypeText), + handler.NewColumn(HostedLoginTranslationSequenceCol, handler.ColumnTypeInt64), + handler.NewColumn(HostedLoginTranslationLocaleCol, handler.ColumnTypeText), + handler.NewColumn(HostedLoginTranslationFileCol, handler.ColumnTypeJSONB), + handler.NewColumn(HostedLoginTranslationEtagCol, handler.ColumnTypeText), + }, + handler.NewPrimaryKey( + HostedLoginTranslationInstanceIDCol, + HostedLoginTranslationAggregateIDCol, + HostedLoginTranslationAggregateTypeCol, + HostedLoginTranslationLocaleCol, + ), + ), + ) +} + +func (hltp *hostedLoginTranslationProjection) Name() string { + return HostedLoginTranslationTable +} + +func (hltp *hostedLoginTranslationProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: org.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: org.HostedLoginTranslationSet, + Reduce: hltp.reduceSet, + }, + }, + }, + { + Aggregate: instance.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: instance.HostedLoginTranslationSet, + Reduce: hltp.reduceSet, + }, + }, + }, + } +} + +func (hltp *hostedLoginTranslationProjection) reduceSet(e eventstore.Event) (*handler.Statement, error) { + + switch e := e.(type) { + case *org.HostedLoginTranslationSetEvent: + orgEvent := *e + return handler.NewUpsertStatement( + &orgEvent, + []handler.Column{ + handler.NewCol(HostedLoginTranslationInstanceIDCol, nil), + handler.NewCol(HostedLoginTranslationAggregateIDCol, nil), + handler.NewCol(HostedLoginTranslationAggregateTypeCol, nil), + handler.NewCol(HostedLoginTranslationLocaleCol, nil), + }, + []handler.Column{ + handler.NewCol(HostedLoginTranslationInstanceIDCol, orgEvent.Aggregate().InstanceID), + handler.NewCol(HostedLoginTranslationAggregateIDCol, orgEvent.Aggregate().ID), + handler.NewCol(HostedLoginTranslationAggregateTypeCol, orgEvent.Aggregate().Type), + handler.NewCol(HostedLoginTranslationCreationDateCol, handler.OnlySetValueOnInsert(HostedLoginTranslationTable, orgEvent.CreationDate())), + handler.NewCol(HostedLoginTranslationChangeDateCol, orgEvent.CreationDate()), + handler.NewCol(HostedLoginTranslationSequenceCol, orgEvent.Sequence()), + handler.NewCol(HostedLoginTranslationLocaleCol, orgEvent.Language), + handler.NewCol(HostedLoginTranslationFileCol, orgEvent.Translation), + handler.NewCol(HostedLoginTranslationEtagCol, hltp.computeEtag(orgEvent.Translation)), + }, + ), nil + case *instance.HostedLoginTranslationSetEvent: + instanceEvent := *e + return handler.NewUpsertStatement( + &instanceEvent, + []handler.Column{ + handler.NewCol(HostedLoginTranslationInstanceIDCol, nil), + handler.NewCol(HostedLoginTranslationAggregateIDCol, nil), + handler.NewCol(HostedLoginTranslationAggregateTypeCol, nil), + handler.NewCol(HostedLoginTranslationLocaleCol, nil), + }, + []handler.Column{ + handler.NewCol(HostedLoginTranslationInstanceIDCol, instanceEvent.Aggregate().InstanceID), + handler.NewCol(HostedLoginTranslationAggregateIDCol, instanceEvent.Aggregate().ID), + handler.NewCol(HostedLoginTranslationAggregateTypeCol, instanceEvent.Aggregate().Type), + handler.NewCol(HostedLoginTranslationCreationDateCol, handler.OnlySetValueOnInsert(HostedLoginTranslationTable, instanceEvent.CreationDate())), + handler.NewCol(HostedLoginTranslationChangeDateCol, instanceEvent.CreationDate()), + handler.NewCol(HostedLoginTranslationSequenceCol, instanceEvent.Sequence()), + handler.NewCol(HostedLoginTranslationLocaleCol, instanceEvent.Language), + handler.NewCol(HostedLoginTranslationFileCol, instanceEvent.Translation), + handler.NewCol(HostedLoginTranslationEtagCol, hltp.computeEtag(instanceEvent.Translation)), + }, + ), nil + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-AZshaa", "reduce.wrong.event.type %v", []eventstore.EventType{org.HostedLoginTranslationSet}) + } + +} + +func (hltp *hostedLoginTranslationProjection) computeEtag(translation map[string]any) string { + hash := md5.Sum(fmt.Append(nil, translation)) + return hex.EncodeToString(hash[:]) +} diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index 77a28ac79a..5ad62380ea 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -86,6 +86,7 @@ var ( UserSchemaProjection *handler.Handler WebKeyProjection *handler.Handler DebugEventsProjection *handler.Handler + HostedLoginTranslationProjection *handler.Handler ProjectGrantFields *handler.FieldHandler OrgDomainVerifiedFields *handler.FieldHandler @@ -179,6 +180,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, UserSchemaProjection = newUserSchemaProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["user_schemas"])) WebKeyProjection = newWebKeyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["web_keys"])) DebugEventsProjection = newDebugEventsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["debug_events"])) + HostedLoginTranslationProjection = newHostedLoginTranslationProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["hosted_login_translation"])) ProjectGrantFields = newFillProjectGrantFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsProjectGrant])) OrgDomainVerifiedFields = newFillOrgDomainVerifiedFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsOrgDomainVerified])) @@ -357,5 +359,6 @@ func newProjectionsList() { UserSchemaProjection, WebKeyProjection, DebugEventsProjection, + HostedLoginTranslationProjection, } } diff --git a/internal/query/v2-default.json b/internal/query/v2-default.json new file mode 100644 index 0000000000..c86396ef34 --- /dev/null +++ b/internal/query/v2-default.json @@ -0,0 +1,1557 @@ +{ + "de":{ + "common": { + "back": "Zurück" + }, + "accounts": { + "title": "Konten", + "description": "Wählen Sie das Konto aus, das Sie verwenden möchten.", + "addAnother": "Ein weiteres Konto hinzufügen", + "noResults": "Keine Konten gefunden" + }, + "loginname": { + "title": "Willkommen zurück!", + "description": "Geben Sie Ihre Anmeldedaten ein.", + "register": "Neuen Benutzer registrieren" + }, + "password": { + "verify": { + "title": "Passwort", + "description": "Geben Sie Ihr Passwort ein.", + "resetPassword": "Passwort zurücksetzen", + "submit": "Weiter" + }, + "set": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "codeSent": "Ein Code wurde an Ihre E-Mail-Adresse gesendet.", + "noCodeReceived": "Keinen Code erhalten?", + "resend": "Erneut senden", + "submit": "Weiter" + }, + "change": { + "title": "Passwort ändern", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "idp": { + "title": "Mit SSO anmelden", + "description": "Wählen Sie einen der folgenden Anbieter, um sich anzumelden", + "signInWithApple": "Mit Apple anmelden", + "signInWithGoogle": "Mit Google anmelden", + "signInWithAzureAD": "Mit AzureAD anmelden", + "signInWithGithub": "Mit GitHub anmelden", + "signInWithGitlab": "Mit GitLab anmelden", + "loginSuccess": { + "title": "Anmeldung erfolgreich", + "description": "Sie haben sich erfolgreich angemeldet!" + }, + "linkingSuccess": { + "title": "Konto verknüpft", + "description": "Sie haben Ihr Konto erfolgreich verknüpft!" + }, + "registerSuccess": { + "title": "Registrierung erfolgreich", + "description": "Sie haben sich erfolgreich registriert!" + }, + "loginError": { + "title": "Anmeldung fehlgeschlagen", + "description": "Beim Anmelden ist ein Fehler aufgetreten." + }, + "linkingError": { + "title": "Konto-Verknüpfung fehlgeschlagen", + "description": "Beim Verknüpfen Ihres Kontos ist ein Fehler aufgetreten." + } + }, + "mfa": { + "verify": { + "title": "Bestätigen Sie Ihre Identität", + "description": "Wählen Sie einen der folgenden Faktoren.", + "noResults": "Keine zweiten Faktoren verfügbar, um sie einzurichten." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Wählen Sie einen der folgenden zweiten Faktoren.", + "skip": "Überspringen" + } + }, + "otp": { + "verify": { + "title": "2-Faktor bestätigen", + "totpDescription": "Geben Sie den Code aus Ihrer Authentifizierungs-App ein.", + "smsDescription": "Geben Sie den Code ein, den Sie per SMS erhalten haben.", + "emailDescription": "Geben Sie den Code ein, den Sie per E-Mail erhalten haben.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "submit": "Weiter" + }, + "set": { + "title": "2-Faktor einrichten", + "totpDescription": "Scannen Sie den QR-Code mit Ihrer Authentifizierungs-App.", + "smsDescription": "Geben Sie Ihre Telefonnummer ein, um einen Code per SMS zu erhalten.", + "emailDescription": "Geben Sie Ihre E-Mail-Adresse ein, um einen Code per E-Mail zu erhalten.", + "totpRegisterDescription": "Scannen Sie den QR-Code oder navigieren Sie manuell zur URL.", + "submit": "Weiter" + } + }, + "passkey": { + "verify": { + "title": "Mit einem Passkey authentifizieren", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "usePassword": "Passwort verwenden", + "submit": "Weiter" + }, + "set": { + "title": "Passkey einrichten", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "info": { + "description": "Ein Passkey ist eine Authentifizierungsmethode auf einem Gerät wie Ihr Fingerabdruck, Apple FaceID oder ähnliches.", + "link": "Passwortlose Authentifizierung" + }, + "skip": "Überspringen", + "submit": "Weiter" + } + }, + "u2f": { + "verify": { + "title": "2-Faktor bestätigen", + "description": "Bestätigen Sie Ihr Konto mit Ihrem Gerät." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Richten Sie ein Gerät als zweiten Faktor ein.", + "submit": "Weiter" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registrierung deaktiviert", + "description": "Die Registrierung ist deaktiviert. Bitte wenden Sie sich an den Administrator." + }, + "missingdata": { + "title": "Registrierung fehlgeschlagen", + "description": "Einige Daten fehlen. Bitte überprüfen Sie Ihre Eingaben." + }, + "title": "Registrieren", + "description": "Erstellen Sie Ihr ZITADEL-Konto.", + "selectMethod": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten", + "agreeTo": "Um sich zu registrieren, müssen Sie den Nutzungsbedingungen zustimmen", + "termsOfService": "Nutzungsbedingungen", + "privacyPolicy": "Datenschutzrichtlinie", + "submit": "Weiter", + "password": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "invite": { + "title": "Benutzer einladen", + "description": "Geben Sie die E-Mail-Adresse des Benutzers ein, den Sie einladen möchten.", + "info": "Der Benutzer erhält eine E-Mail mit einem Link, um sich zu registrieren.", + "notAllowed": "Sie haben keine Berechtigung, Benutzer einzuladen.", + "submit": "Einladen", + "success": { + "title": "Einladung erfolgreich", + "description": "Der Benutzer wurde erfolgreich eingeladen.", + "verified": "Der Benutzer wurde eingeladen und hat seine E-Mail bereits verifiziert.", + "notVerifiedYet": "Der Benutzer wurde eingeladen. Er erhält eine E-Mail mit weiteren Anweisungen.", + "submit": "Weiteren Benutzer einladen" + } + }, + "signedin": { + "title": "Willkommen {user}!", + "description": "Sie sind angemeldet.", + "continue": "Weiter", + "error": { + "title": "Fehler", + "description": "Ein Fehler ist aufgetreten." + } + }, + "verify": { + "userIdMissing": "Keine Benutzer-ID angegeben!", + "success": "Erfolgreich verifiziert", + "setupAuthenticator": "Authentifikator einrichten", + "verify": { + "title": "Benutzer verifizieren", + "description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "submit": "Weiter" + } + }, + "authenticator": { + "title": "Authentifizierungsmethode auswählen", + "description": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten.", + "noMethodsAvailable": "Keine Authentifizierungsmethoden verfügbar", + "allSetup": "Sie haben bereits einen Authentifikator eingerichtet!", + "linkWithIDP": "oder verknüpfe mit einem Identitätsanbieter" + }, + "device": { + "usercode": { + "title": "Gerätecode", + "description": "Geben Sie den Code ein.", + "submit": "Weiter" + }, + "request": { + "title": "{appName} möchte eine Verbindung herstellen:", + "disclaimer": "{appName} hat Zugriff auf:", + "description": "Durch Klicken auf Zulassen erlauben Sie {appName} und Zitadel, Ihre Informationen gemäß ihren jeweiligen Nutzungsbedingungen und Datenschutzrichtlinien zu verwenden. Sie können diesen Zugriff jederzeit widerrufen.", + "submit": "Zulassen", + "deny": "Ablehnen" + }, + "scope": { + "openid": "Überprüfen Ihrer Identität.", + "email": "Zugriff auf Ihre E-Mail-Adresse.", + "profile": "Zugriff auf Ihre vollständigen Profilinformationen.", + "offline_access": "Erlauben Sie den Offline-Zugriff auf Ihr Konto." + } + }, + "error": { + "noUserCode": "Kein Benutzercode angegeben!", + "noDeviceRequest": " Es wurde keine Geräteanforderung gefunden. Bitte überprüfen Sie die URL.", + "unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.", + "sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", + "failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.", + "tryagain": "Erneut versuchen" + } + }, + "en":{ + "common": { + "back": "Back" + }, + "accounts": { + "title": "Accounts", + "description": "Select the account you want to use.", + "addAnother": "Add another account", + "noResults": "No accounts found" + }, + "loginname": { + "title": "Welcome back!", + "description": "Enter your login data.", + "register": "Register new user" + }, + "password": { + "verify": { + "title": "Password", + "description": "Enter your password.", + "resetPassword": "Reset Password", + "submit": "Continue" + }, + "set": { + "title": "Set Password", + "description": "Set the password for your account", + "codeSent": "A code has been sent to your email address.", + "noCodeReceived": "Didn't receive a code?", + "resend": "Resend code", + "submit": "Continue" + }, + "change": { + "title": "Change Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "idp": { + "title": "Sign in with SSO", + "description": "Select one of the following providers to sign in", + "signInWithApple": "Sign in with Apple", + "signInWithGoogle": "Sign in with Google", + "signInWithAzureAD": "Sign in with AzureAD", + "signInWithGithub": "Sign in with GitHub", + "signInWithGitlab": "Sign in with GitLab", + "loginSuccess": { + "title": "Login successful", + "description": "You have successfully been loggedIn!" + }, + "linkingSuccess": { + "title": "Account linked", + "description": "You have successfully linked your account!" + }, + "registerSuccess": { + "title": "Registration successful", + "description": "You have successfully registered!" + }, + "loginError": { + "title": "Login failed", + "description": "An error occurred while trying to login." + }, + "linkingError": { + "title": "Account linking failed", + "description": "An error occurred while trying to link your account." + } + }, + "mfa": { + "verify": { + "title": "Verify your identity", + "description": "Choose one of the following factors.", + "noResults": "No second factors available to setup." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Choose one of the following second factors.", + "skip": "Skip" + } + }, + "otp": { + "verify": { + "title": "Verify 2-Factor", + "totpDescription": "Enter the code from your authenticator app.", + "smsDescription": "Enter the code you received via SMS.", + "emailDescription": "Enter the code you received via email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "submit": "Continue" + }, + "set": { + "title": "Set up 2-Factor", + "totpDescription": "Scan the QR code with your authenticator app.", + "smsDescription": "Enter your phone number to receive a code via SMS.", + "emailDescription": "Enter your email address to receive a code via email.", + "totpRegisterDescription": "Scan the QR Code or navigate to the URL manually.", + "submit": "Continue" + } + }, + "passkey": { + "verify": { + "title": "Authenticate with a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "usePassword": "Use password", + "submit": "Continue" + }, + "set": { + "title": "Setup a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "info": { + "description": "A passkey is an authentication method on a device like your fingerprint, Apple FaceID or similar. ", + "link": "Passwordless Authentication" + }, + "skip": "Skip", + "submit": "Continue" + } + }, + "u2f": { + "verify": { + "title": "Verify 2-Factor", + "description": "Verify your account with your device." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Set up a device as a second factor.", + "submit": "Continue" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "The registration is disabled. Please contact your administrator." + }, + "missingdata": { + "title": "Missing data", + "description": "Provide email, first and last name to register." + }, + "title": "Register", + "description": "Create your ZITADEL account.", + "selectMethod": "Select the method you would like to authenticate", + "agreeTo": "To register you must agree to the terms and conditions", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "submit": "Continue", + "password": { + "title": "Set Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "invite": { + "title": "Invite User", + "description": "Provide the email address and the name of the user you want to invite.", + "info": "The user will receive an email with further instructions.", + "notAllowed": "Your settings do not allow you to invite users.", + "submit": "Continue", + "success": { + "title": "User invited", + "description": "The email has successfully been sent.", + "verified": "The user has been invited and has already verified his email.", + "notVerifiedYet": "The user has been invited. They will receive an email with further instructions.", + "submit": "Invite another user" + } + }, + "signedin": { + "title": "Welcome {user}!", + "description": "You are signed in.", + "continue": "Continue", + "error": { + "title": "Error", + "description": "An error occurred while trying to sign in." + } + }, + "verify": { + "userIdMissing": "No userId provided!", + "success": "The user has been verified successfully.", + "setupAuthenticator": "Setup authenticator", + "verify": { + "title": "Verify user", + "description": "Enter the Code provided in the verification email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "submit": "Continue" + } + }, + "authenticator": { + "title": "Choose authentication method", + "description": "Select the method you would like to authenticate", + "noMethodsAvailable": "No authentication methods available", + "allSetup": "You have already setup an authenticator!", + "linkWithIDP": "or link with an Identity Provider" + }, + "device": { + "usercode": { + "title": "Device code", + "description": "Enter the code displayed on your app or device.", + "submit": "Continue" + }, + "request": { + "title": "{appName} would like to connect", + "description": "{appName} will have access to:", + "disclaimer": "By clicking Allow, you allow {appName} and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", + "submit": "Allow", + "deny": "Deny" + }, + "scope": { + "openid": "Verify your identity.", + "email": "View your email address.", + "profile": "View your full profile information.", + "offline_access": "Allow offline access to your account." + } + }, + "error": { + "noUserCode": "No user code provided!", + "noDeviceRequest": "No device request found.", + "unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.", + "sessionExpired": "Your current session has expired. Please login again.", + "failedLoading": "Failed to load data. Please try again.", + "tryagain": "Try Again" + } + }, + "es":{ + "common": { + "back": "Atrás" + }, + "accounts": { + "title": "Cuentas", + "description": "Selecciona la cuenta que deseas usar.", + "addAnother": "Agregar otra cuenta", + "noResults": "No se encontraron cuentas" + }, + "loginname": { + "title": "¡Bienvenido de nuevo!", + "description": "Introduce tus datos de acceso.", + "register": "Registrar nuevo usuario" + }, + "password": { + "verify": { + "title": "Contraseña", + "description": "Introduce tu contraseña.", + "resetPassword": "Restablecer contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "codeSent": "Se ha enviado un código a su correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resend": "Reenviar código", + "submit": "Continuar" + }, + "change": { + "title": "Cambiar Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "idp": { + "title": "Iniciar sesión con SSO", + "description": "Selecciona uno de los siguientes proveedores para iniciar sesión", + "signInWithApple": "Iniciar sesión con Apple", + "signInWithGoogle": "Iniciar sesión con Google", + "signInWithAzureAD": "Iniciar sesión con AzureAD", + "signInWithGithub": "Iniciar sesión con GitHub", + "signInWithGitlab": "Iniciar sesión con GitLab", + "loginSuccess": { + "title": "Inicio de sesión exitoso", + "description": "¡Has iniciado sesión con éxito!" + }, + "linkingSuccess": { + "title": "Cuenta vinculada", + "description": "¡Has vinculado tu cuenta con éxito!" + }, + "registerSuccess": { + "title": "Registro exitoso", + "description": "¡Te has registrado con éxito!" + }, + "loginError": { + "title": "Error de inicio de sesión", + "description": "Ocurrió un error al intentar iniciar sesión." + }, + "linkingError": { + "title": "Error al vincular la cuenta", + "description": "Ocurrió un error al intentar vincular tu cuenta." + } + }, + "mfa": { + "verify": { + "title": "Verifica tu identidad", + "description": "Elige uno de los siguientes factores.", + "noResults": "No hay factores secundarios disponibles para configurar." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Elige uno de los siguientes factores secundarios.", + "skip": "Omitir" + } + }, + "otp": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "totpDescription": "Introduce el código de tu aplicación de autenticación.", + "smsDescription": "Introduce el código que recibiste por SMS.", + "emailDescription": "Introduce el código que recibiste por correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "submit": "Continuar" + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "totpDescription": "Escanea el código QR con tu aplicación de autenticación.", + "smsDescription": "Introduce tu número de teléfono para recibir un código por SMS.", + "emailDescription": "Introduce tu dirección de correo electrónico para recibir un código por correo electrónico.", + "totpRegisterDescription": "Escanea el código QR o navega manualmente a la URL.", + "submit": "Continuar" + } + }, + "passkey": { + "verify": { + "title": "Autenticar con una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "usePassword": "Usar contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Configurar una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "info": { + "description": "Una clave de acceso es un método de autenticación en un dispositivo como tu huella digital, Apple FaceID o similar.", + "link": "Autenticación sin contraseña" + }, + "skip": "Omitir", + "submit": "Continuar" + } + }, + "u2f": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "description": "Verifica tu cuenta con tu dispositivo." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Configura un dispositivo como segundo factor.", + "submit": "Continuar" + } + }, + "register": { + "methods": { + "passkey": "Clave de acceso", + "password": "Contraseña" + }, + "disabled": { + "title": "Registro deshabilitado", + "description": "Registrarse está deshabilitado en este momento." + }, + "missingdata": { + "title": "Datos faltantes", + "description": "No se proporcionaron datos suficientes para el registro." + }, + "title": "Registrarse", + "description": "Crea tu cuenta ZITADEL.", + "selectMethod": "Selecciona el método con el que deseas autenticarte", + "agreeTo": "Para registrarte debes aceptar los términos y condiciones", + "termsOfService": "Términos de Servicio", + "privacyPolicy": "Política de Privacidad", + "submit": "Continuar", + "password": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "invite": { + "title": "Invitar usuario", + "description": "Introduce el correo electrónico del usuario que deseas invitar.", + "info": "El usuario recibirá un correo electrónico con un enlace para completar el registro.", + "notAllowed": "No tienes permiso para invitar usuarios.", + "submit": "Invitar usuario", + "success": { + "title": "¡Usuario invitado!", + "description": "El usuario ha sido invitado.", + "verified": "El usuario ha sido invitado y ya ha verificado su correo electrónico.", + "notVerifiedYet": "El usuario ha sido invitado. Recibirá un correo electrónico con más instrucciones.", + "submit": "Invitar a otro usuario" + } + }, + "signedin": { + "title": "¡Bienvenido {user}!", + "description": "Has iniciado sesión.", + "continue": "Continuar", + "error": { + "title": "Error", + "description": "Ocurrió un error al iniciar sesión." + } + }, + "verify": { + "userIdMissing": "¡No se proporcionó userId!", + "success": "¡Verificación exitosa!", + "setupAuthenticator": "Configurar autenticador", + "verify": { + "title": "Verificar usuario", + "description": "Introduce el código proporcionado en el correo electrónico de verificación.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "submit": "Continuar" + } + }, + "authenticator": { + "title": "Seleccionar método de autenticación", + "description": "Selecciona el método con el que deseas autenticarte", + "noMethodsAvailable": "No hay métodos de autenticación disponibles", + "allSetup": "¡Ya has configurado un autenticador!", + "linkWithIDP": "o vincúlalo con un proveedor de identidad" + }, + "device": { + "usercode": { + "title": "Código del dispositivo", + "description": "Introduce el código.", + "submit": "Continuar" + }, + "request": { + "title": "{appName} desea conectarse:", + "description": "{appName} tendrá acceso a:", + "disclaimer": "Al hacer clic en Permitir, autorizas a {appName} y a Zitadel a usar tu información de acuerdo con sus respectivos términos de servicio y políticas de privacidad. Puedes revocar este acceso en cualquier momento.", + "submit": "Permitir", + "deny": "Denegar" + }, + "scope": { + "openid": "Verifica tu identidad.", + "email": "Accede a tu dirección de correo electrónico.", + "profile": "Accede a la información completa de tu perfil.", + "offline_access": "Permitir acceso sin conexión a tu cuenta." + } + }, + "error": { + "noUserCode": "¡No se proporcionó código de usuario!", + "noDeviceRequest": "No se encontró ninguna solicitud de dispositivo.", + "unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.", + "sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.", + "failedLoading": "No se pudieron cargar los datos. Por favor, inténtalo de nuevo.", + "tryagain": "Intentar de nuevo" + } + }, + "it":{ + "common": { + "back": "Indietro" + }, + "accounts": { + "title": "Account", + "description": "Seleziona l'account che desideri utilizzare.", + "addAnother": "Aggiungi un altro account", + "noResults": "Nessun account trovato" + }, + "loginname": { + "title": "Bentornato!", + "description": "Inserisci i tuoi dati di accesso.", + "register": "Registrati come nuovo utente" + }, + "password": { + "verify": { + "title": "Password", + "description": "Inserisci la tua password.", + "resetPassword": "Reimposta Password", + "submit": "Continua" + }, + "set": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "codeSent": "Un codice è stato inviato al tuo indirizzo email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resend": "Invia di nuovo", + "submit": "Continua" + }, + "change": { + "title": "Cambia Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "idp": { + "title": "Accedi con SSO", + "description": "Seleziona uno dei seguenti provider per accedere", + "signInWithApple": "Accedi con Apple", + "signInWithGoogle": "Accedi con Google", + "signInWithAzureAD": "Accedi con AzureAD", + "signInWithGithub": "Accedi con GitHub", + "signInWithGitlab": "Accedi con GitLab", + "loginSuccess": { + "title": "Accesso riuscito", + "description": "Accesso effettuato con successo!" + }, + "linkingSuccess": { + "title": "Account collegato", + "description": "Hai collegato con successo il tuo account!" + }, + "registerSuccess": { + "title": "Registrazione riuscita", + "description": "Registrazione effettuata con successo!" + }, + "loginError": { + "title": "Accesso fallito", + "description": "Si è verificato un errore durante il tentativo di accesso." + }, + "linkingError": { + "title": "Collegamento account fallito", + "description": "Si è verificato un errore durante il tentativo di collegare il tuo account." + } + }, + "mfa": { + "verify": { + "title": "Verifica la tua identità", + "description": "Scegli uno dei seguenti fattori.", + "noResults": "Nessun secondo fattore disponibile per la configurazione." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Scegli uno dei seguenti secondi fattori.", + "skip": "Salta" + } + }, + "otp": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "totpDescription": "Inserisci il codice dalla tua app di autenticazione.", + "smsDescription": "Inserisci il codice ricevuto via SMS.", + "emailDescription": "Inserisci il codice ricevuto via email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "submit": "Continua" + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "totpDescription": "Scansiona il codice QR con la tua app di autenticazione.", + "smsDescription": "Inserisci il tuo numero di telefono per ricevere un codice via SMS.", + "emailDescription": "Inserisci il tuo indirizzo email per ricevere un codice via email.", + "totpRegisterDescription": "Scansiona il codice QR o naviga manualmente all'URL.", + "submit": "Continua" + } + }, + "passkey": { + "verify": { + "title": "Autenticati con una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "usePassword": "Usa password", + "submit": "Continua" + }, + "set": { + "title": "Configura una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "info": { + "description": "Una passkey è un metodo di autenticazione su un dispositivo come la tua impronta digitale, Apple FaceID o simili.", + "link": "Autenticazione senza password" + }, + "skip": "Salta", + "submit": "Continua" + } + }, + "u2f": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "description": "Verifica il tuo account con il tuo dispositivo." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Configura un dispositivo come secondo fattore.", + "submit": "Continua" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "Registrazione disabilitata. Contatta l'amministratore di sistema per assistenza." + }, + "missingdata": { + "title": "Registrazione", + "description": "Inserisci i tuoi dati per registrarti." + }, + "title": "Registrati", + "description": "Crea il tuo account ZITADEL.", + "selectMethod": "Seleziona il metodo con cui desideri autenticarti", + "agreeTo": "Per registrarti devi accettare i termini e le condizioni", + "termsOfService": "Termini di Servizio", + "privacyPolicy": "Informativa sulla Privacy", + "submit": "Continua", + "password": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "invite": { + "title": "Invita Utente", + "description": "Inserisci l'indirizzo email dell'utente che desideri invitare.", + "info": "L'utente riceverà un'email con ulteriori istruzioni.", + "notAllowed": "Non hai i permessi per invitare un utente.", + "submit": "Invita Utente", + "success": { + "title": "Invito inviato", + "description": "L'utente è stato invitato con successo.", + "verified": "L'utente è stato invitato e ha già verificato la sua email.", + "notVerifiedYet": "L'utente è stato invitato. Riceverà un'email con ulteriori istruzioni.", + "submit": "Invita un altro utente" + } + }, + "signedin": { + "title": "Benvenuto {user}!", + "description": "Sei connesso.", + "continue": "Continua", + "error": { + "title": "Errore", + "description": "Si è verificato un errore durante il tentativo di accesso." + } + }, + "verify": { + "userIdMissing": "Nessun userId fornito!", + "success": "Verifica effettuata con successo!", + "setupAuthenticator": "Configura autenticatore", + "verify": { + "title": "Verifica utente", + "description": "Inserisci il codice fornito nell'email di verifica.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "submit": "Continua" + } + }, + "authenticator": { + "title": "Seleziona metodo di autenticazione", + "description": "Seleziona il metodo con cui desideri autenticarti", + "noMethodsAvailable": "Nessun metodo di autenticazione disponibile", + "allSetup": "Hai già configurato un autenticatore!", + "linkWithIDP": "o collega con un Identity Provider" + }, + "device": { + "usercode": { + "title": "Codice dispositivo", + "description": "Inserisci il codice.", + "submit": "Continua" + }, + "request": { + "title": "{appName} desidera connettersi:", + "description": "{appName} avrà accesso a:", + "disclaimer": "Cliccando su Consenti, autorizzi {appName} e Zitadel a utilizzare le tue informazioni in conformità con i rispettivi termini di servizio e politiche sulla privacy. Puoi revocare questo accesso in qualsiasi momento.", + "submit": "Consenti", + "deny": "Nega" + }, + "scope": { + "openid": "Verifica la tua identità.", + "email": "Accedi al tuo indirizzo email.", + "profile": "Accedi alle informazioni complete del tuo profilo.", + "offline_access": "Consenti l'accesso offline al tuo account." + } + }, + "error": { + "noUserCode": "Nessun codice utente fornito!", + "noDeviceRequest": "Nessuna richiesta di dispositivo trovata.", + "unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.", + "sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.", + "failedLoading": "Impossibile caricare i dati. Riprova.", + "tryagain": "Riprova" + } + + }, + "pl":{ + "common": { + "back": "Powrót" + }, + "accounts": { + "title": "Konta", + "description": "Wybierz konto, którego chcesz użyć.", + "addAnother": "Dodaj kolejne konto", + "noResults": "Nie znaleziono kont" + }, + "loginname": { + "title": "Witamy ponownie!", + "description": "Wprowadź dane logowania.", + "register": "Zarejestruj nowego użytkownika" + }, + "password": { + "verify": { + "title": "Hasło", + "description": "Wprowadź swoje hasło.", + "resetPassword": "Zresetuj hasło", + "submit": "Kontynuuj" + }, + "set": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "codeSent": "Kod został wysłany na twój adres e-mail.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resend": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "change": { + "title": "Zmień hasło", + "description": "Ustaw nowe hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "idp": { + "title": "Zaloguj się za pomocą SSO", + "description": "Wybierz jednego z poniższych dostawców, aby się zalogować", + "signInWithApple": "Zaloguj się przez Apple", + "signInWithGoogle": "Zaloguj się przez Google", + "signInWithAzureAD": "Zaloguj się przez AzureAD", + "signInWithGithub": "Zaloguj się przez GitHub", + "signInWithGitlab": "Zaloguj się przez GitLab", + "loginSuccess": { + "title": "Logowanie udane", + "description": "Zostałeś pomyślnie zalogowany!" + }, + "linkingSuccess": { + "title": "Konto powiązane", + "description": "Pomyślnie powiązałeś swoje konto!" + }, + "registerSuccess": { + "title": "Rejestracja udana", + "description": "Pomyślnie się zarejestrowałeś!" + }, + "loginError": { + "title": "Logowanie nieudane", + "description": "Wystąpił błąd podczas próby logowania." + }, + "linkingError": { + "title": "Powiązanie konta nie powiodło się", + "description": "Wystąpił błąd podczas próby powiązania konta." + } + }, + "mfa": { + "verify": { + "title": "Zweryfikuj swoją tożsamość", + "description": "Wybierz jeden z poniższych sposobów weryfikacji.", + "noResults": "Nie znaleziono dostępnych metod uwierzytelniania dwuskładnikowego." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Wybierz jedną z poniższych metod drugiego czynnika.", + "skip": "Pomiń" + } + }, + "otp": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Wprowadź kod z aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź kod otrzymany SMS-em.", + "emailDescription": "Wprowadź kod otrzymany e-mailem.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Zeskanuj kod QR za pomocą aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź swój numer telefonu, aby otrzymać kod SMS-em.", + "emailDescription": "Wprowadź swój adres e-mail, aby otrzymać kod e-mailem.", + "totpRegisterDescription": "Zeskanuj kod QR lub otwórz adres URL ręcznie.", + "submit": "Kontynuuj" + } + }, + "passkey": { + "verify": { + "title": "Uwierzytelnij się za pomocą klucza dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "usePassword": "Użyj hasła", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj klucz dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "info": { + "description": "Klucz dostępu to metoda uwierzytelniania na urządzeniu, wykorzystująca np. odcisk palca, Apple FaceID lub podobne rozwiązania.", + "link": "Uwierzytelnianie bez hasła" + }, + "skip": "Pomiń", + "submit": "Kontynuuj" + } + }, + "u2f": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "description": "Zweryfikuj swoje konto za pomocą urządzenia." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Skonfiguruj urządzenie jako dodatkowy czynnik uwierzytelniania.", + "submit": "Kontynuuj" + } + }, + "register": { + "methods": { + "passkey": "Klucz dostępu", + "password": "Hasło" + }, + "disabled": { + "title": "Rejestracja wyłączona", + "description": "Rejestracja jest wyłączona. Skontaktuj się z administratorem." + }, + "missingdata": { + "title": "Brak danych", + "description": "Podaj e-mail, imię i nazwisko, aby się zarejestrować." + }, + "title": "Rejestracja", + "description": "Utwórz konto ZITADEL.", + "selectMethod": "Wybierz metodę uwierzytelniania, której chcesz użyć", + "agreeTo": "Aby się zarejestrować, musisz zaakceptować warunki korzystania", + "termsOfService": "Regulamin", + "privacyPolicy": "Polityka prywatności", + "submit": "Kontynuuj", + "password": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "invite": { + "title": "Zaproś użytkownika", + "description": "Podaj adres e-mail oraz imię i nazwisko użytkownika, którego chcesz zaprosić.", + "info": "Użytkownik otrzyma e-mail z dalszymi instrukcjami.", + "notAllowed": "Twoje ustawienia nie pozwalają na zapraszanie użytkowników.", + "submit": "Kontynuuj", + "success": { + "title": "Użytkownik zaproszony", + "description": "E-mail został pomyślnie wysłany.", + "verified": "Użytkownik został zaproszony i już zweryfikował swój e-mail.", + "notVerifiedYet": "Użytkownik został zaproszony. Otrzyma e-mail z dalszymi instrukcjami.", + "submit": "Zaproś kolejnego użytkownika" + } + }, + "signedin": { + "title": "Witaj {user}!", + "description": "Jesteś zalogowany.", + "continue": "Kontynuuj", + "error": { + "title": "Błąd", + "description": "Nie można załadować danych. Sprawdź połączenie z internetem lub spróbuj ponownie później." + } + }, + "verify": { + "userIdMissing": "Nie podano identyfikatora użytkownika!", + "success": "Użytkownik został pomyślnie zweryfikowany.", + "setupAuthenticator": "Skonfiguruj uwierzytelnianie", + "verify": { + "title": "Zweryfikuj użytkownika", + "description": "Wprowadź kod z wiadomości weryfikacyjnej.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "submit": "Kontynuuj" + } + }, + "authenticator": { + "title": "Wybierz metodę uwierzytelniania", + "description": "Wybierz metodę, której chcesz użyć do uwierzytelnienia.", + "noMethodsAvailable": "Brak dostępnych metod uwierzytelniania", + "allSetup": "Już skonfigurowałeś metodę uwierzytelniania!", + "linkWithIDP": "lub połącz z dostawcą tożsamości" + }, + "device": { + "usercode": { + "title": "Kod urządzenia", + "description": "Wprowadź kod.", + "submit": "Kontynuuj" + }, + "request": { + "title": "{appName} chce się połączyć:", + "description": "{appName} będzie miało dostęp do:", + "disclaimer": "Klikając Zezwól, pozwalasz tej aplikacji i Zitadel na korzystanie z Twoich informacji zgodnie z ich odpowiednimi warunkami użytkowania i politykami prywatności. Możesz cofnąć ten dostęp w dowolnym momencie.", + "submit": "Zezwól", + "deny": "Odmów" + }, + "scope": { + "openid": "Zweryfikuj swoją tożsamość.", + "email": "Uzyskaj dostęp do swojego adresu e-mail.", + "profile": "Uzyskaj dostęp do pełnych informacji o swoim profilu.", + "offline_access": "Zezwól na dostęp offline do swojego konta." + } + }, + "error": { + "noUserCode": "Nie podano kodu użytkownika!", + "noDeviceRequest": "Nie znaleziono żądania urządzenia.", + "unknownContext": "Nie udało się pobrać kontekstu użytkownika. Upewnij się, że najpierw wprowadziłeś nazwę użytkownika lub podałeś login jako parametr wyszukiwania.", + "sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.", + "failedLoading": "Nie udało się załadować danych. Spróbuj ponownie.", + "tryagain": "Spróbuj ponownie" + } + }, + "ru":{ + "common": { + "back": "Назад" + }, + "accounts": { + "title": "Аккаунты", + "description": "Выберите аккаунт, который хотите использовать.", + "addAnother": "Добавить другой аккаунт", + "noResults": "Аккаунты не найдены" + }, + "loginname": { + "title": "С возвращением!", + "description": "Введите свои данные для входа.", + "register": "Зарегистрировать нового пользователя" + }, + "password": { + "verify": { + "title": "Пароль", + "description": "Введите ваш пароль.", + "resetPassword": "Сбросить пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "codeSent": "Код отправлен на ваш адрес электронной почты.", + "noCodeReceived": "Не получили код?", + "resend": "Отправить код повторно", + "submit": "Продолжить" + }, + "change": { + "title": "Изменить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "idp": { + "title": "Войти через SSO", + "description": "Выберите одного из провайдеров для входа", + "signInWithApple": "Войти через Apple", + "signInWithGoogle": "Войти через Google", + "signInWithAzureAD": "Войти через AzureAD", + "signInWithGithub": "Войти через GitHub", + "signInWithGitlab": "Войти через GitLab", + "loginSuccess": { + "title": "Вход выполнен успешно", + "description": "Вы успешно вошли в систему!" + }, + "linkingSuccess": { + "title": "Аккаунт привязан", + "description": "Аккаунт успешно привязан!" + }, + "registerSuccess": { + "title": "Регистрация завершена", + "description": "Вы успешно зарегистрировались!" + }, + "loginError": { + "title": "Ошибка входа", + "description": "Произошла ошибка при попытке входа." + }, + "linkingError": { + "title": "Ошибка привязки аккаунта", + "description": "Произошла ошибка при попытке привязать аккаунт." + } + }, + "mfa": { + "verify": { + "title": "Подтвердите вашу личность", + "description": "Выберите один из следующих факторов.", + "noResults": "Нет доступных методов двухфакторной аутентификации" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Выберите один из следующих методов.", + "skip": "Пропустить" + } + }, + "otp": { + "verify": { + "title": "Подтверждение 2FA", + "totpDescription": "Введите код из приложения-аутентификатора.", + "smsDescription": "Введите код, полученный по SMS.", + "emailDescription": "Введите код, полученный по email.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "totpDescription": "Отсканируйте QR-код в приложении-аутентификаторе.", + "smsDescription": "Введите номер телефона для получения кода по SMS.", + "emailDescription": "Введите email для получения кода.", + "totpRegisterDescription": "Отсканируйте QR-код или перейдите по ссылке вручную.", + "submit": "Продолжить" + } + }, + "passkey": { + "verify": { + "title": "Аутентификация с помощью пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "usePassword": "Использовать пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "info": { + "description": "Пасскей — метод аутентификации через устройство (отпечаток пальца, Apple FaceID и аналоги).", + "link": "Аутентификация без пароля" + }, + "skip": "Пропустить", + "submit": "Продолжить" + } + }, + "u2f": { + "verify": { + "title": "Подтверждение 2FA", + "description": "Подтвердите аккаунт с помощью устройства." + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Настройте устройство как второй фактор.", + "submit": "Продолжить" + } + }, + "register": { + "methods": { + "passkey": "Пасскей", + "password": "Пароль" + }, + "disabled": { + "title": "Регистрация отключена", + "description": "Регистрация недоступна. Обратитесь к администратору." + }, + "missingdata": { + "title": "Недостаточно данных", + "description": "Укажите email, имя и фамилию для регистрации." + }, + "title": "Регистрация", + "description": "Создайте свой аккаунт ZITADEL.", + "selectMethod": "Выберите метод аутентификации", + "agreeTo": "Для регистрации необходимо принять условия:", + "termsOfService": "Условия использования", + "privacyPolicy": "Политика конфиденциальности", + "submit": "Продолжить", + "password": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "invite": { + "title": "Пригласить пользователя", + "description": "Укажите email и имя пользователя для приглашения.", + "info": "Пользователь получит email с инструкциями.", + "notAllowed": "Ваши настройки не позволяют приглашать пользователей.", + "submit": "Продолжить", + "success": { + "title": "Пользователь приглашён", + "description": "Письмо успешно отправлено.", + "verified": "Пользователь приглашён и уже подтвердил email.", + "notVerifiedYet": "Пользователь приглашён. Он получит email с инструкциями.", + "submit": "Пригласить другого пользователя" + } + }, + "signedin": { + "title": "Добро пожаловать, {user}!", + "description": "Вы вошли в систему.", + "continue": "Продолжить", + "error": { + "title": "Ошибка", + "description": "Не удалось войти в систему. Проверьте свои данные и попробуйте снова." + } + }, + "verify": { + "userIdMissing": "Не указан userId!", + "success": "Пользователь успешно подтверждён.", + "setupAuthenticator": "Настроить аутентификатор", + "verify": { + "title": "Подтверждение пользователя", + "description": "Введите код из письма подтверждения.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "submit": "Продолжить" + } + }, + "authenticator": { + "title": "Выбор метода аутентификации", + "description": "Выберите предпочитаемый метод аутентификации", + "noMethodsAvailable": "Нет доступных методов аутентификации", + "allSetup": "Аутентификатор уже настроен!", + "linkWithIDP": "или привязать через Identity Provider" + }, + "device": { + "usercode": { + "title": "Код устройства", + "description": "Введите код.", + "submit": "Продолжить" + }, + "request": { + "title": "{appName} хочет подключиться:", + "description": "{appName} получит доступ к:", + "disclaimer": "Нажимая «Разрешить», вы разрешаете этому приложению и Zitadel использовать вашу информацию в соответствии с их условиями использования и политиками конфиденциальности. Вы можете отозвать этот доступ в любое время.", + "submit": "Разрешить", + "deny": "Запретить" + }, + "scope": { + "openid": "Проверка вашей личности.", + "email": "Доступ к вашему адресу электронной почты.", + "profile": "Доступ к полной информации вашего профиля.", + "offline_access": "Разрешить офлайн-доступ к вашему аккаунту." + } + }, + "error": { + "noUserCode": "Не указан код пользователя!", + "noDeviceRequest": "Не найдена ни одна заявка на устройство.", + "unknownContext": "Не удалось получить контекст пользователя. Укажите имя пользователя или loginName в параметрах поиска.", + "sessionExpired": "Ваша сессия истекла. Войдите снова.", + "failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.", + "tryagain": "Попробовать снова" + } + }, + "zh":{ + "common": { + "back": "返回" + }, + "accounts": { + "title": "账户", + "description": "选择您想使用的账户。", + "addAnother": "添加另一个账户", + "noResults": "未找到账户" + }, + "loginname": { + "title": "欢迎回来!", + "description": "请输入您的登录信息。", + "register": "注册新用户" + }, + "password": { + "verify": { + "title": "密码", + "description": "请输入您的密码。", + "resetPassword": "重置密码", + "submit": "继续" + }, + "set": { + "title": "设置密码", + "description": "为您的账户设置密码", + "codeSent": "验证码已发送到您的邮箱。", + "noCodeReceived": "没有收到验证码?", + "resend": "重发验证码", + "submit": "继续" + }, + "change": { + "title": "更改密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "idp": { + "title": "使用 SSO 登录", + "description": "选择以下提供商中的一个进行登录", + "signInWithApple": "用 Apple 登录", + "signInWithGoogle": "用 Google 登录", + "signInWithAzureAD": "用 AzureAD 登录", + "signInWithGithub": "用 GitHub 登录", + "signInWithGitlab": "用 GitLab 登录", + "loginSuccess": { + "title": "登录成功", + "description": "您已成功登录!" + }, + "linkingSuccess": { + "title": "账户已链接", + "description": "您已成功链接您的账户!" + }, + "registerSuccess": { + "title": "注册成功", + "description": "您已成功注册!" + }, + "loginError": { + "title": "登录失败", + "description": "登录时发生错误。" + }, + "linkingError": { + "title": "账户链接失败", + "description": "链接账户时发生错误。" + } + }, + "mfa": { + "verify": { + "title": "验证您的身份", + "description": "选择以下的一个因素。", + "noResults": "没有可设置的第二因素。" + }, + "set": { + "title": "设置双因素认证", + "description": "选择以下的一个第二因素。", + "skip": "跳过" + } + }, + "otp": { + "verify": { + "title": "验证双因素", + "totpDescription": "请输入认证应用程序中的验证码。", + "smsDescription": "输入通过短信收到的验证码。", + "emailDescription": "输入通过电子邮件收到的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "submit": "继续" + }, + "set": { + "title": "设置双因素认证", + "totpDescription": "使用认证应用程序扫描二维码。", + "smsDescription": "输入您的电话号码以接收短信验证码。", + "emailDescription": "输入您的电子邮箱地址以接收电子邮件验证码。", + "totpRegisterDescription": "扫描二维码或手动导航到URL。", + "submit": "继续" + } + }, + "passkey": { + "verify": { + "title": "使用密钥认证", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "usePassword": "使用密码", + "submit": "继续" + }, + "set": { + "title": "设置密钥", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "info": { + "description": "密钥是在设备上如指纹、Apple FaceID 或类似的认证方法。", + "link": "无密码认证" + }, + "skip": "跳过", + "submit": "继续" + } + }, + "u2f": { + "verify": { + "title": "验证双因素", + "description": "使用您的设备验证帐户。" + }, + "set": { + "title": "设置双因素认证", + "description": "设置设备为第二因素。", + "submit": "继续" + } + }, + "register": { + "methods": { + "passkey": "密钥", + "password": "密码" + }, + "disabled": { + "title": "注册已禁用", + "description": "您的设置不允许注册新用户。" + }, + "missingdata": { + "title": "缺少数据", + "description": "请提供所有必需的数据。" + }, + "title": "注册", + "description": "创建您的 ZITADEL 账户。", + "selectMethod": "选择您想使用的认证方法", + "agreeTo": "注册即表示您同意条款和条件", + "termsOfService": "服务条款", + "privacyPolicy": "隐私政策", + "submit": "继续", + "password": { + "title": "设置密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "invite": { + "title": "邀请用户", + "description": "提供您想邀请的用户的电子邮箱地址和姓名。", + "info": "用户将收到一封包含进一步说明的电子邮件。", + "notAllowed": "您的设置不允许邀请用户。", + "submit": "继续", + "success": { + "title": "用户已邀请", + "description": "邮件已成功发送。", + "verified": "用户已被邀请并已验证其电子邮件。", + "notVerifiedYet": "用户已被邀请。他们将收到一封包含进一步说明的电子邮件。", + "submit": "邀请另一位用户" + } + }, + "signedin": { + "title": "欢迎 {user}!", + "description": "您已登录。", + "continue": "继续", + "error": { + "title": "错误", + "description": "登录时发生错误。" + } + }, + "verify": { + "userIdMissing": "未提供用户 ID!", + "success": "用户验证成功。", + "setupAuthenticator": "设置认证器", + "verify": { + "title": "验证用户", + "description": "输入验证邮件中的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "submit": "继续" + } + }, + "authenticator": { + "title": "选择认证方式", + "description": "选择您想使用的认证方法", + "noMethodsAvailable": "没有可用的认证方法", + "allSetup": "您已经设置好了一个认证器!", + "linkWithIDP": "或将其与身份提供者关联" + }, + "device": { + "usercode": { + "title": "设备代码", + "description": "输入代码。", + "submit": "继续" + }, + "request": { + "title": "{appName} 想要连接:", + "description": "{appName} 将访问:", + "disclaimer": "点击“允许”即表示您允许此应用程序和 Zitadel 根据其各自的服务条款和隐私政策使用您的信息。您可以随时撤销此访问权限。", + "submit": "允许", + "deny": "拒绝" + }, + "scope": { + "openid": "验证您的身份。", + "email": "访问您的电子邮件地址。", + "profile": "访问您的完整个人资料信息。", + "offline_access": "允许离线访问您的账户。" + } + }, + "error": { + "noUserCode": "未提供用户代码!", + "noDeviceRequest": "没有找到设备请求。", + "unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。", + "sessionExpired": "当前会话已过期,请重新登录。", + "failedLoading": "加载数据失败,请再试一次。", + "tryagain": "重试" + } + } +} \ No newline at end of file diff --git a/internal/repository/instance/eventstore.go b/internal/repository/instance/eventstore.go index 68621597a8..b8089152bb 100644 --- a/internal/repository/instance/eventstore.go +++ b/internal/repository/instance/eventstore.go @@ -130,4 +130,5 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, NotificationPolicyChangedEventType, NotificationPolicyChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, TrustedDomainAddedEventType, eventstore.GenericEventMapper[TrustedDomainAddedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, TrustedDomainRemovedEventType, eventstore.GenericEventMapper[TrustedDomainRemovedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, HostedLoginTranslationSet, HostedLoginTranslationSetEventMapper) } diff --git a/internal/repository/instance/hosted_login_translation.go b/internal/repository/instance/hosted_login_translation.go new file mode 100644 index 0000000000..05380521fc --- /dev/null +++ b/internal/repository/instance/hosted_login_translation.go @@ -0,0 +1,55 @@ +package instance + +import ( + "context" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + HostedLoginTranslationSet = instanceEventTypePrefix + "hosted_login_translation.set" +) + +type HostedLoginTranslationSetEvent struct { + eventstore.BaseEvent `json:"-"` + + Translation map[string]any `json:"translation,omitempty"` + Language language.Tag `json:"language,omitempty"` + Level string `json:"level,omitempty"` +} + +func NewHostedLoginTranslationSetEvent(ctx context.Context, aggregate *eventstore.Aggregate, translation map[string]any, language language.Tag) *HostedLoginTranslationSetEvent { + return &HostedLoginTranslationSetEvent{ + BaseEvent: *eventstore.NewBaseEventForPush(ctx, aggregate, HostedLoginTranslationSet), + Translation: translation, + Language: language, + Level: string(aggregate.Type), + } +} + +func (e *HostedLoginTranslationSetEvent) Payload() any { + return e +} + +func (e *HostedLoginTranslationSetEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *HostedLoginTranslationSetEvent) Fields() []*eventstore.FieldOperation { + return nil +} + +func HostedLoginTranslationSetEventMapper(event eventstore.Event) (eventstore.Event, error) { + translationSet := &HostedLoginTranslationSetEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := event.Unmarshal(translationSet) + if err != nil { + return nil, zerrors.ThrowInternal(err, "INST-lOxtJJ", "unable to unmarshal hosted login translation set event") + } + + return translationSet, nil +} diff --git a/internal/repository/org/eventstore.go b/internal/repository/org/eventstore.go index d1efa75dfc..289bbbc608 100644 --- a/internal/repository/org/eventstore.go +++ b/internal/repository/org/eventstore.go @@ -114,4 +114,5 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, NotificationPolicyAddedEventType, NotificationPolicyAddedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, NotificationPolicyChangedEventType, NotificationPolicyChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, NotificationPolicyRemovedEventType, NotificationPolicyRemovedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, HostedLoginTranslationSet, HostedLoginTranslationSetEventMapper) } diff --git a/internal/repository/org/hosted_login_translation.go b/internal/repository/org/hosted_login_translation.go new file mode 100644 index 0000000000..e07bdc1e3b --- /dev/null +++ b/internal/repository/org/hosted_login_translation.go @@ -0,0 +1,55 @@ +package org + +import ( + "context" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + HostedLoginTranslationSet = orgEventTypePrefix + "hosted_login_translation.set" +) + +type HostedLoginTranslationSetEvent struct { + eventstore.BaseEvent `json:"-"` + + Translation map[string]any `json:"translation,omitempty"` + Language language.Tag `json:"language,omitempty"` + Level string `json:"level,omitempty"` +} + +func NewHostedLoginTranslationSetEvent(ctx context.Context, aggregate *eventstore.Aggregate, translation map[string]any, language language.Tag) *HostedLoginTranslationSetEvent { + return &HostedLoginTranslationSetEvent{ + BaseEvent: *eventstore.NewBaseEventForPush(ctx, aggregate, HostedLoginTranslationSet), + Translation: translation, + Language: language, + Level: string(aggregate.Type), + } +} + +func (e *HostedLoginTranslationSetEvent) Payload() any { + return e +} + +func (e *HostedLoginTranslationSetEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *HostedLoginTranslationSetEvent) Fields() []*eventstore.FieldOperation { + return nil +} + +func HostedLoginTranslationSetEventMapper(event eventstore.Event) (eventstore.Event, error) { + translationSet := &HostedLoginTranslationSetEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := event.Unmarshal(translationSet) + if err != nil { + return nil, zerrors.ThrowInternal(err, "ORG-BH82Eb", "unable to unmarshal hosted login translation set event") + } + + return translationSet, nil +} diff --git a/proto/zitadel/settings/v2/settings.proto b/proto/zitadel/settings/v2/settings.proto index b3ca5b5ca5..c797d27965 100644 --- a/proto/zitadel/settings/v2/settings.proto +++ b/proto/zitadel/settings/v2/settings.proto @@ -10,4 +10,4 @@ enum ResourceOwnerType { RESOURCE_OWNER_TYPE_UNSPECIFIED = 0; RESOURCE_OWNER_TYPE_INSTANCE = 1; RESOURCE_OWNER_TYPE_ORG = 2; -} +} \ No newline at end of file diff --git a/proto/zitadel/settings/v2/settings_service.proto b/proto/zitadel/settings/v2/settings_service.proto index 7f71e08da4..0a1f13e7e7 100644 --- a/proto/zitadel/settings/v2/settings_service.proto +++ b/proto/zitadel/settings/v2/settings_service.proto @@ -15,6 +15,8 @@ import "google/api/annotations.proto"; import "google/api/field_behavior.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; +import "google/protobuf/struct.proto"; +import "zitadel/settings/v2/settings.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2;settings"; @@ -362,6 +364,69 @@ service SettingsService { description: "Set the security settings of the ZITADEL instance." }; } + + // Get Hosted Login Translation + // + // Returns the translations in the requested locale for the hosted login. + // The translations returned are based on the input level specified (system, instance or organization). + // + // If the requested level doesn't contain all translations, and ignore_inheritance is set to false, + // a merging process fallbacks onto the higher levels ensuring all keys in the file have a translation, + // which could be in the default language if the one of the locale is missing on all levels. + // + // The etag returned in the response represents the hash of the translations as they are stored on DB + // and its reliable only if ignore_inheritance = true. + // + // Required permissions: + // - `iam.policy.read` + rpc GetHostedLoginTranslation(GetHostedLoginTranslationRequest) returns (GetHostedLoginTranslationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The localized translations."; + } + }; + }; + + option (google.api.http) = { + get: "/v2/settings/hosted_login_translation" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.policy.read" + } + }; + } + + // Set Hosted Login Translation + // + // Sets the input translations at the specified level (instance or organization) for the input language. + // + // Required permissions: + // - `iam.policy.write` + rpc SetHostedLoginTranslation(SetHostedLoginTranslationRequest) returns (SetHostedLoginTranslationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The translations was successfully set."; + } + }; + }; + + option (google.api.http) = { + put: "/v2/settings/hosted_login_translation"; + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.policy.write" + } + }; + } } message GetLoginSettingsRequest { @@ -480,4 +545,76 @@ message SetSecuritySettingsRequest{ message SetSecuritySettingsResponse{ zitadel.object.v2.Details details = 1; +} + +message GetHostedLoginTranslationRequest { + oneof level { + bool system = 1 [(validate.rules).bool = {const: true}]; + bool instance = 2 [(validate.rules).bool = {const: true}]; + string organization_id = 3; + } + + string locale = 4 [ + (validate.rules).string = {min_len: 2}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 2; + example: "\"fr-FR\""; + } + ]; + + // if set to true, higher levels are ignored, if false higher levels are merged into the file + bool ignore_inheritance = 5; +} + +message GetHostedLoginTranslationResponse { + // hash of the payload + string etag = 1 [ + (validate.rules).string = {min_len: 32, max_len: 32}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 32; + max_length: 32; + example: "\"42a1ba123e6ea6f0c93e286ed97c7018\""; + } + ]; + + google.protobuf.Struct translations = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "{\"common\":{\"back\":\"Indietro\"},\"accounts\":{\"title\":\"Account\",\"description\":\"Seleziona l'account che desideri utilizzare.\",\"addAnother\":\"Aggiungi un altro account\",\"noResults\":\"Nessun account trovato\"}}"; + description: "Translations contains the translations in the request language."; + } + ]; +} + +message SetHostedLoginTranslationRequest { + oneof level { + bool instance = 1 [(validate.rules).bool = {const: true}]; + string organization_id = 2; + } + + string locale = 3 [ + (validate.rules).string = {min_len: 2}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 2; + example: "\"fr-FR\""; + } + ]; + + google.protobuf.Struct translations = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "{\"common\":{\"back\":\"Indietro\"},\"accounts\":{\"title\":\"Account\",\"description\":\"Seleziona l'account che desideri utilizzare.\",\"addAnother\":\"Aggiungi un altro account\",\"noResults\":\"Nessun account trovato\"}}"; + description: "Translations should contain the translations in the specified locale."; + } + ]; +} + +message SetHostedLoginTranslationResponse { + // hash of the saved translation. Valid only when ignore_inheritance = true + string etag = 1 [ + (validate.rules).string = {min_len: 32, max_len: 32}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 32; + max_length: 32; + example: "\"42a1ba123e6ea6f0c93e286ed97c7018\""; + } + ]; } \ No newline at end of file From 3a4298c1794ad61c4fa3d3d186aa4235b8742424 Mon Sep 17 00:00:00 2001 From: Trong Huu Nguyen Date: Thu, 19 Jun 2025 11:42:44 +0200 Subject: [PATCH 03/66] fix(scim): add type attribute to ScimEmail (#9690) # Which Problems Are Solved - SCIM PATCH operations for users from Entra ID for the `emails` attribute fails due to missing `type` subattribute # How the Problems Are Solved - Adds the `type` attribute to the `ScimUser` struct and sets the default value to `"work"` in the `mapWriteModelToScimUser()` method. # Additional Changes # Additional Context The SCIM handlers for POST and PUT ignore multiple emails and only uses the primary email for a given user, or falls back to the first email if none are marked as primary. PATCH operations however, will attempt to resolve the provided filter in `operations[].path`. Some services, such as Entra ID, only support patching emails by filtering for `emails[type eq "(work|home|other)"].value`, which fails with Zitadel as the ScimUser struct (and thus the generated schema) doesn't include the `type` field. This commit adds the `type` field to work around this issue, while still preserving compatibility with filters such as `emails[primary eq true].value`. - https://discord.com/channels/927474939156643850/927866013545025566/1356556668527448191 --------- Co-authored-by: Christer Edvartsen Co-authored-by: Thomas Siegfried Krampl --- ...vice_provider_config_expected_schemas.json | 11 +++++ ..._provider_config_expected_user_schema.json | 11 +++++ ..._replace_test_minimal_with_email_type.json | 17 ++++++++ .../integration_test/users_create_test.go | 1 + .../scim/integration_test/users_get_test.go | 1 + .../integration_test/users_replace_test.go | 40 ++++++++++++++++++- .../integration_test/users_update_test.go | 9 +++++ internal/api/scim/metadata/metadata.go | 3 ++ internal/api/scim/resources/user.go | 1 + internal/api/scim/resources/user_mapping.go | 4 ++ internal/api/scim/resources/user_metadata.go | 5 ++- .../api/scim/resources/user_patch_test.go | 34 ++++++++++++++++ 12 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json diff --git a/internal/api/scim/integration_test/testdata/service_provider_config_expected_schemas.json b/internal/api/scim/integration_test/testdata/service_provider_config_expected_schemas.json index bc87b8e2e1..2751c85a79 100644 --- a/internal/api/scim/integration_test/testdata/service_provider_config_expected_schemas.json +++ b/internal/api/scim/integration_test/testdata/service_provider_config_expected_schemas.json @@ -233,6 +233,17 @@ "mutability": "readWrite", "returned": "always", "uniqueness": "none" + }, + { + "name": "type", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" } ], "multiValued": true, diff --git a/internal/api/scim/integration_test/testdata/service_provider_config_expected_user_schema.json b/internal/api/scim/integration_test/testdata/service_provider_config_expected_user_schema.json index a199fe1465..35d0e356b3 100644 --- a/internal/api/scim/integration_test/testdata/service_provider_config_expected_user_schema.json +++ b/internal/api/scim/integration_test/testdata/service_provider_config_expected_user_schema.json @@ -225,6 +225,17 @@ "mutability": "readWrite", "returned": "always", "uniqueness": "none" + }, + { + "name": "type", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" } ], "multiValued": true, diff --git a/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json b/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json new file mode 100644 index 0000000000..b7e8d87590 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json @@ -0,0 +1,17 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "userName": "acmeUser1-minimal-replaced", + "name": { + "familyName": "Ross-replaced", + "givenName": "Bethany-replaced" + }, + "emails": [ + { + "value": "user1-minimal-replaced@example.com", + "primary": true, + "type": "work" + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/users_create_test.go b/internal/api/scim/integration_test/users_create_test.go index 8b6986666c..35d5297878 100644 --- a/internal/api/scim/integration_test/users_create_test.go +++ b/internal/api/scim/integration_test/users_create_test.go @@ -391,6 +391,7 @@ func TestCreateUser_metadata(t *testing.T) { test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:locale", "en-US") test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:ims", `[{"value":"someaimhandle","type":"aim"},{"value":"twitterhandle","type":"X"}]`) test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:roles", `[{"value":"my-role-1","display":"Rolle 1","type":"main-role","primary":true},{"value":"my-role-2","display":"Rolle 2","type":"secondary-role"}]`) + test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", `[{"value":"bjensen@example.com","primary":true,"type":"work"},{"value":"babs@jensen.org","primary":false,"type":"home"}]`) }, retryDuration, tick) } diff --git a/internal/api/scim/integration_test/users_get_test.go b/internal/api/scim/integration_test/users_get_test.go index 0106e47ca2..8a1bab6c93 100644 --- a/internal/api/scim/integration_test/users_get_test.go +++ b/internal/api/scim/integration_test/users_get_test.go @@ -115,6 +115,7 @@ func TestGetUser(t *testing.T) { { Value: "bjensen@example.com", Primary: true, + Type: "work", }, }, PhoneNumbers: []*resources.ScimPhoneNumber{ diff --git a/internal/api/scim/integration_test/users_replace_test.go b/internal/api/scim/integration_test/users_replace_test.go index 770ed06959..1c99592b01 100644 --- a/internal/api/scim/integration_test/users_replace_test.go +++ b/internal/api/scim/integration_test/users_replace_test.go @@ -27,6 +27,9 @@ var ( //go:embed testdata/users_replace_test_minimal_with_external_id.json minimalUserWithExternalIDJson []byte + //go:embed testdata/users_replace_test_minimal_with_email_type.json + minimalUserWithEmailTypeReplaceJson []byte + //go:embed testdata/users_replace_test_minimal.json minimalUserReplaceJson []byte @@ -303,7 +306,42 @@ func TestReplaceUser_removeOldMetadata(t *testing.T) { Id: createdUser.ID, }) require.NoError(tt, err) - require.Equal(tt, 0, len(md.Result)) + require.Equal(tt, 1, len(md.Result)) + + mdMap := make(map[string]string) + for i := range md.Result { + mdMap[md.Result[i].Key] = string(md.Result[i].Value) + } + + test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", "[{\"value\":\"user1@example.com\",\"primary\":true}]") + }, retryDuration, tick) + + _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) + require.NoError(t, err) +} + +func TestReplaceUser_emailType(t *testing.T) { + // ensure old metadata is removed correctly + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + require.NoError(t, err) + + _, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, minimalUserWithEmailTypeReplaceJson) + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{ + Id: createdUser.ID, + }) + require.NoError(tt, err) + require.Equal(tt, 1, len(md.Result)) + + mdMap := make(map[string]string) + for i := range md.Result { + mdMap[md.Result[i].Key] = string(md.Result[i].Value) + } + + test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", "[{\"value\":\"user1-minimal-replaced@example.com\",\"primary\":true,\"type\":\"work\"}]") }, retryDuration, tick) _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) diff --git a/internal/api/scim/integration_test/users_update_test.go b/internal/api/scim/integration_test/users_update_test.go index 2fe129291a..77e55bac60 100644 --- a/internal/api/scim/integration_test/users_update_test.go +++ b/internal/api/scim/integration_test/users_update_test.go @@ -137,9 +137,18 @@ func TestUpdateUser(t *testing.T) { NickName: "", ProfileUrl: test.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")), Emails: []*resources.ScimEmail{ + { + Value: "bjensen@example.com", + Type: "work", + }, + { + Value: "babs@jensen.org", + Type: "home", + }, { Value: "babs@example.com", Primary: true, + Type: "home", }, }, Addresses: []*resources.ScimAddress{ diff --git a/internal/api/scim/metadata/metadata.go b/internal/api/scim/metadata/metadata.go index 66a0a2483c..28e42290d1 100644 --- a/internal/api/scim/metadata/metadata.go +++ b/internal/api/scim/metadata/metadata.go @@ -30,6 +30,7 @@ const ( KeyAddresses Key = KeyPrefix + "addresses" KeyEntitlements Key = KeyPrefix + "entitlements" KeyRoles Key = KeyPrefix + "roles" + KeyEmails Key = KeyPrefix + "emails" ) var ( @@ -47,6 +48,7 @@ var ( KeyAddresses, KeyEntitlements, KeyRoles, + KeyEmails, } AttributePathToMetadataKeys = map[string][]Key{ @@ -64,6 +66,7 @@ var ( "addresses": {KeyAddresses}, "entitlements": {KeyEntitlements}, "roles": {KeyRoles}, + "emails": {KeyEmails}, } ) diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go index 13baed5d51..6506ae35c7 100644 --- a/internal/api/scim/resources/user.go +++ b/internal/api/scim/resources/user.go @@ -90,6 +90,7 @@ type ScimIms struct { type ScimEmail struct { Value string `json:"value" scim:"required"` Primary bool `json:"primary"` + Type string `json:"type,omitempty"` } type ScimPhoneNumber struct { diff --git a/internal/api/scim/resources/user_mapping.go b/internal/api/scim/resources/user_mapping.go index 171af87238..260e50846a 100644 --- a/internal/api/scim/resources/user_mapping.go +++ b/internal/api/scim/resources/user_mapping.go @@ -382,6 +382,10 @@ func (h *UsersHandler) mapAndValidateMetadata(ctx context.Context, user *ScimUse if err := extractJsonMetadata(ctx, md, metadata.KeyRoles, &user.Roles); err != nil { logging.OnError(err).Warn("Could not deserialize scim roles metadata") } + + if err := extractJsonMetadata(ctx, md, metadata.KeyEmails, &user.Emails); err != nil { + logging.OnError(err).Warn("Could not deserialize scim emails metadata") + } } func (h *UsersHandler) buildResourceForQuery(ctx context.Context, user *query.User) *schemas.Resource { diff --git a/internal/api/scim/resources/user_metadata.go b/internal/api/scim/resources/user_metadata.go index 69d85e40e5..3e018507fe 100644 --- a/internal/api/scim/resources/user_metadata.go +++ b/internal/api/scim/resources/user_metadata.go @@ -129,7 +129,8 @@ func getValueForMetadataKey(user *ScimUser, key metadata.Key) ([]byte, error) { metadata.KeyAddresses, metadata.KeyEntitlements, metadata.KeyIms, - metadata.KeyPhotos: + metadata.KeyPhotos, + metadata.KeyEmails: val, err := json.Marshal(value) if err != nil { return nil, err @@ -223,6 +224,8 @@ func getRawValueForMetadataKey(user *ScimUser, key metadata.Key) interface{} { return user.Locale case metadata.KeyTimezone: return user.Timezone + case metadata.KeyEmails: + return user.Emails case metadata.KeyProvisioningDomain: break } diff --git a/internal/api/scim/resources/user_patch_test.go b/internal/api/scim/resources/user_patch_test.go index ff3fc720bf..0c8aadc388 100644 --- a/internal/api/scim/resources/user_patch_test.go +++ b/internal/api/scim/resources/user_patch_test.go @@ -685,6 +685,39 @@ func TestOperationCollection_Apply(t *testing.T) { }, wantErr: true, }, + { + name: "replace filter complex subattribute multiple emails primary value", + op: &patch.Operation{ + Operation: patch.OperationTypeReplace, + Path: test.Must(filter.ParsePath(`emails[primary eq true].value`)), + Value: json.RawMessage(`"jeanie.rebecca.pendleton@example.com"`), + }, + want: &ScimUser{ + Emails: []*ScimEmail{ + { + Value: "jeanie.rebecca.pendleton@example.com", + Primary: true, + }, + }, + }, + }, + { + name: "replace filter complex subattribute multiple emails type value", + op: &patch.Operation{ + Operation: patch.OperationTypeReplace, + Path: test.Must(filter.ParsePath(`emails[type eq "work"].value`)), + Value: json.RawMessage(`"jeanie.rebecca.pendleton@example.com"`), + }, + want: &ScimUser{ + Emails: []*ScimEmail{ + { + Value: "jeanie.rebecca.pendleton@example.com", + Primary: true, + Type: "work", + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -711,6 +744,7 @@ func TestOperationCollection_Apply(t *testing.T) { { Value: "jeanie.pendleton@example.com", Primary: true, + Type: "work", }, }, PhoneNumbers: []*ScimPhoneNumber{ From fa9de9a0f123269cc257b849cec03b4ab316c133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 24 Jun 2025 12:41:41 +0300 Subject: [PATCH 04/66] feat: generate webkeys setup step (#10105) # Which Problems Are Solved We are preparing to roll-out and stabilize webkeys in the next version of Zitadel. Before removing legacy signing-key code, we must ensure all existing instances have their webkeys generated. # How the Problems Are Solved Add a setup step which generate 2 webkeys for each existing instance that didn't have webkeys yet. # Additional Changes Return an error from the config type-switch, when the type is unknown. # Additional Context - Part 1/2 of https://github.com/zitadel/zitadel/issues/10029 - Should be back-ported to v3 --- cmd/setup/59.go | 54 ++++++++++++++++++++++++++++++++++++++ cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 ++ internal/crypto/web_key.go | 3 +++ 4 files changed, 60 insertions(+) create mode 100644 cmd/setup/59.go diff --git a/cmd/setup/59.go b/cmd/setup/59.go new file mode 100644 index 0000000000..530937d1a5 --- /dev/null +++ b/cmd/setup/59.go @@ -0,0 +1,54 @@ +package setup + +import ( + "context" + "fmt" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" +) + +type SetupWebkeys struct { + eventstore *eventstore.Eventstore + commands *command.Commands +} + +func (mig *SetupWebkeys) Execute(ctx context.Context, _ eventstore.Event) error { + instances, err := mig.eventstore.InstanceIDs( + ctx, + eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs). + OrderDesc(). + AddQuery(). + AggregateTypes(instance.AggregateType). + EventTypes(instance.InstanceAddedEventType). + Builder().ExcludeAggregateIDs(). + AggregateTypes(instance.AggregateType). + EventTypes(instance.InstanceRemovedEventType). + Builder(), + ) + if err != nil { + return fmt.Errorf("%s get instance IDs: %w", mig, err) + } + conf := &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + } + + for _, instance := range instances { + ctx := authz.WithInstanceID(ctx, instance) + logging.Info("prepare initial webkeys for instance", "instance_id", instance, "migration", mig) + if err := mig.commands.GenerateInitialWebKeys(ctx, conf); err != nil { + return fmt.Errorf("%s generate initial webkeys: %w", mig, err) + } + } + return nil +} + +func (mig *SetupWebkeys) String() string { + return "59_setup_webkeys" +} diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 0c3f726902..7385cc7652 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -155,6 +155,7 @@ type Steps struct { s56IDPTemplate6SAMLFederatedLogout *IDPTemplate6SAMLFederatedLogout s57CreateResourceCounts *CreateResourceCounts s58ReplaceLoginNames3View *ReplaceLoginNames3View + s59SetupWebkeys *SetupWebkeys } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 8ee8d7fc68..dd23c320c7 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -272,6 +272,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) } commands, _, _, _ := startCommandsQueries(ctx, eventstoreClient, eventstoreV4, dbClient, masterKey, config) + steps.s59SetupWebkeys = &SetupWebkeys{eventstore: eventstoreClient, commands: commands} repeatableSteps := []migration.RepeatableMigration{ &externalConfigChange{ @@ -321,6 +322,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s42Apps7OIDCConfigsLoginVersion, steps.s43CreateFieldsDomainIndex, steps.s48Apps7SAMLConfigsLoginVersion, + steps.s59SetupWebkeys, // this step needs commands. } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil { diff --git a/internal/crypto/web_key.go b/internal/crypto/web_key.go index c769cb1213..286305259b 100644 --- a/internal/crypto/web_key.go +++ b/internal/crypto/web_key.go @@ -7,6 +7,7 @@ import ( "crypto/rand" "crypto/rsa" "encoding/json" + "fmt" "github.com/go-jose/go-jose/v4" "github.com/muhlemmer/gu" @@ -219,6 +220,8 @@ func generateWebKey(keyID string, genConfig WebKeyConfig) (private, public *jose key, err = ecdsa.GenerateKey(conf.GetCurve(), rand.Reader) case *WebKeyED25519Config: _, key, err = ed25519.GenerateKey(rand.Reader) + default: + return nil, nil, fmt.Errorf("unknown webkey config type %T", genConfig) } if err != nil { return nil, nil, err From 1719bbaba5f3a4a7aa8c29fd46414d4f65e76396 Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Tue, 24 Jun 2025 23:02:12 -0700 Subject: [PATCH 05/66] chore(docs): update docusaurus to 3.8.1 (#10115) This pull request updates several dependencies in the `docs/package.json` file to their latest minor versions, ensuring compatibility and access to the latest features and fixes. Dependency updates: * Updated `@docusaurus/core`, `@docusaurus/faster`, `@docusaurus/preset-classic`, `@docusaurus/theme-mermaid`, and `@docusaurus/theme-search-algolia` from version `^3.8.0` to `^3.8.1` in the `dependencies` section. * Updated `@docusaurus/module-type-aliases` and `@docusaurus/types` from version `^3.8.0` to `^3.8.1` in the `devDependencies` section. Co-authored-by: Florian Forster --- docs/package.json | 14 +- docs/yarn.lock | 750 ++++++++++++++++++++++++---------------------- 2 files changed, 399 insertions(+), 365 deletions(-) diff --git a/docs/package.json b/docs/package.json index 014a8ec0ca..2c9eb8bb84 100644 --- a/docs/package.json +++ b/docs/package.json @@ -22,11 +22,11 @@ }, "dependencies": { "@bufbuild/buf": "^1.14.0", - "@docusaurus/core": "^3.8.0", - "@docusaurus/faster": "^3.8.0", - "@docusaurus/preset-classic": "^3.8.0", - "@docusaurus/theme-mermaid": "^3.8.0", - "@docusaurus/theme-search-algolia": "^3.8.0", + "@docusaurus/core": "^3.8.1", + "@docusaurus/faster": "^3.8.1", + "@docusaurus/preset-classic": "^3.8.1", + "@docusaurus/theme-mermaid": "^3.8.1", + "@docusaurus/theme-search-algolia": "^3.8.1", "@headlessui/react": "^1.7.4", "@heroicons/react": "^2.0.13", "autoprefixer": "^10.4.13", @@ -57,8 +57,8 @@ ] }, "devDependencies": { - "@docusaurus/module-type-aliases": "^3.8.0", - "@docusaurus/types": "^3.8.0", + "@docusaurus/module-type-aliases": "^3.8.1", + "@docusaurus/types": "^3.8.1", "tailwindcss": "^3.2.4" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" diff --git a/docs/yarn.lock b/docs/yarn.lock index 70f2de1f05..c933386f97 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -2347,10 +2347,10 @@ resolved "https://registry.yarnpkg.com/@csstools/postcss-initial/-/postcss-initial-2.0.1.tgz#c385bd9d8ad31ad159edd7992069e97ceea4d09a" integrity sha512-L1wLVMSAZ4wovznquK0xmC7QSctzO4D0Is590bxpGqhqjboLXYA16dWZpfwImkdOgACdQ9PqXsuRroW6qPlEsg== -"@csstools/postcss-is-pseudo-class@^5.0.1": - version "5.0.1" - resolved "https://registry.yarnpkg.com/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-5.0.1.tgz#12041448fedf01090dd4626022c28b7f7623f58e" - integrity sha512-JLp3POui4S1auhDR0n8wHd/zTOWmMsmK3nQd3hhL6FhWPaox5W7j1se6zXOG/aP07wV2ww0lxbKYGwbBszOtfQ== +"@csstools/postcss-is-pseudo-class@^5.0.3": + version "5.0.3" + resolved "https://registry.yarnpkg.com/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-5.0.3.tgz#d34e850bcad4013c2ed7abe948bfa0448aa8eb74" + integrity sha512-jS/TY4SpG4gszAtIg7Qnf3AS2pjcUM5SzxpApOrlndMeGhIbaTzWBzzP/IApXoNWEW7OhcjkRT48jnAUIFXhAQ== dependencies: "@csstools/selector-specificity" "^5.0.0" postcss-selector-parser "^7.0.0" @@ -2514,10 +2514,10 @@ resolved "https://registry.yarnpkg.com/@csstools/postcss-unset-value/-/postcss-unset-value-4.0.0.tgz#7caa981a34196d06a737754864baf77d64de4bba" integrity sha512-cBz3tOCI5Fw6NIFEwU3RiwK6mn3nKegjpJuzCndoGq3BZPkUjnsq7uQmIeMNeMbMk7YD2MfKcgCpZwX5jyXqCA== -"@csstools/selector-resolve-nested@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.0.0.tgz#704a9b637975680e025e069a4c58b3beb3e2752a" - integrity sha512-ZoK24Yku6VJU1gS79a5PFmC8yn3wIapiKmPgun0hZgEI5AOqgH2kiPRsPz1qkGv4HL+wuDLH83yQyk6inMYrJQ== +"@csstools/selector-resolve-nested@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.1.0.tgz#848c6f44cb65e3733e478319b9342b7aa436fac7" + integrity sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g== "@csstools/selector-specificity@^5.0.0": version "5.0.0" @@ -2549,10 +2549,10 @@ "@docsearch/css" "3.9.0" algoliasearch "^5.14.2" -"@docusaurus/babel@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/babel/-/babel-3.8.0.tgz#2f390cc4e588a96ec496d87921e44890899738a6" - integrity sha512-9EJwSgS6TgB8IzGk1L8XddJLhZod8fXT4ULYMx6SKqyCBqCFpVCEjR/hNXXhnmtVM2irDuzYoVLGWv7srG/VOA== +"@docusaurus/babel@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/babel/-/babel-3.8.1.tgz#db329ac047184214e08e2dbc809832c696c18506" + integrity sha512-3brkJrml8vUbn9aeoZUlJfsI/GqyFcDgQJwQkmBtclJgWDEQBKKeagZfOgx0WfUQhagL1sQLNW0iBdxnI863Uw== dependencies: "@babel/core" "^7.25.9" "@babel/generator" "^7.25.9" @@ -2564,54 +2564,54 @@ "@babel/runtime" "^7.25.9" "@babel/runtime-corejs3" "^7.25.9" "@babel/traverse" "^7.25.9" - "@docusaurus/logger" "3.8.0" - "@docusaurus/utils" "3.8.0" + "@docusaurus/logger" "3.8.1" + "@docusaurus/utils" "3.8.1" babel-plugin-dynamic-import-node "^2.3.3" fs-extra "^11.1.1" tslib "^2.6.0" -"@docusaurus/bundler@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/bundler/-/bundler-3.8.0.tgz#386f54dca594d81bac6b617c71822e0808d6e2f6" - integrity sha512-Rq4Z/MSeAHjVzBLirLeMcjLIAQy92pF1OI+2rmt18fSlMARfTGLWRE8Vb+ljQPTOSfJxwDYSzsK6i7XloD2rNA== +"@docusaurus/bundler@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/bundler/-/bundler-3.8.1.tgz#e2b11d615f09a6e470774bb36441b8d06736b94c" + integrity sha512-/z4V0FRoQ0GuSLToNjOSGsk6m2lQUG4FRn8goOVoZSRsTrU8YR2aJacX5K3RG18EaX9b+52pN4m1sL3MQZVsQA== dependencies: "@babel/core" "^7.25.9" - "@docusaurus/babel" "3.8.0" - "@docusaurus/cssnano-preset" "3.8.0" - "@docusaurus/logger" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" + "@docusaurus/babel" "3.8.1" + "@docusaurus/cssnano-preset" "3.8.1" + "@docusaurus/logger" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" babel-loader "^9.2.1" - clean-css "^5.3.2" + clean-css "^5.3.3" copy-webpack-plugin "^11.0.0" - css-loader "^6.8.1" + css-loader "^6.11.0" css-minimizer-webpack-plugin "^5.0.1" cssnano "^6.1.2" file-loader "^6.2.0" html-minifier-terser "^7.2.0" - mini-css-extract-plugin "^2.9.1" + mini-css-extract-plugin "^2.9.2" null-loader "^4.0.1" - postcss "^8.4.26" - postcss-loader "^7.3.3" - postcss-preset-env "^10.1.0" + postcss "^8.5.4" + postcss-loader "^7.3.4" + postcss-preset-env "^10.2.1" terser-webpack-plugin "^5.3.9" tslib "^2.6.0" url-loader "^4.1.1" webpack "^5.95.0" webpackbar "^6.0.1" -"@docusaurus/core@3.8.0", "@docusaurus/core@^3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-3.8.0.tgz#79d5e1084415c8834a8a5cb87162ca13f52fe147" - integrity sha512-c7u6zFELmSGPEP9WSubhVDjgnpiHgDqMh1qVdCB7rTflh4Jx0msTYmMiO91Ez0KtHj4sIsDsASnjwfJ2IZp3Vw== +"@docusaurus/core@3.8.1", "@docusaurus/core@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-3.8.1.tgz#c22e47c16a22cb7d245306c64bc54083838ff3db" + integrity sha512-ENB01IyQSqI2FLtOzqSI3qxG2B/jP4gQPahl2C3XReiLebcVh5B5cB9KYFvdoOqOWPyr5gXK4sjgTKv7peXCrA== dependencies: - "@docusaurus/babel" "3.8.0" - "@docusaurus/bundler" "3.8.0" - "@docusaurus/logger" "3.8.0" - "@docusaurus/mdx-loader" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-common" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/babel" "3.8.1" + "@docusaurus/bundler" "3.8.1" + "@docusaurus/logger" "3.8.1" + "@docusaurus/mdx-loader" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-common" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" boxen "^6.2.1" chalk "^4.1.2" chokidar "^3.5.3" @@ -2648,23 +2648,23 @@ webpack-dev-server "^4.15.2" webpack-merge "^6.0.1" -"@docusaurus/cssnano-preset@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/cssnano-preset/-/cssnano-preset-3.8.0.tgz#a70f19e2995be2299f5ef9c3da3e5d4d5c14bff2" - integrity sha512-UJ4hAS2T0R4WNy+phwVff2Q0L5+RXW9cwlH6AEphHR5qw3m/yacfWcSK7ort2pMMbDn8uGrD38BTm4oLkuuNoQ== +"@docusaurus/cssnano-preset@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/cssnano-preset/-/cssnano-preset-3.8.1.tgz#bd55026251a6ab8e2194839a2042458ef9880c44" + integrity sha512-G7WyR2N6SpyUotqhGznERBK+x84uyhfMQM2MmDLs88bw4Flom6TY46HzkRkSEzaP9j80MbTN8naiL1fR17WQug== dependencies: cssnano-preset-advanced "^6.1.2" - postcss "^8.4.38" + postcss "^8.5.4" postcss-sort-media-queries "^5.2.0" tslib "^2.6.0" -"@docusaurus/faster@^3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/faster/-/faster-3.8.0.tgz#2814c5ea4f19e10a6cebf9296b6f15f8a621bf61" - integrity sha512-v9+8rT2gw/4zIRBwc4fIVhrTH/yFVDQgJgyYZjqr3fgojOypdQCOwkN6Z8dOwTei4/zo+b/zDPB4x1UvghJZRg== +"@docusaurus/faster@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/faster/-/faster-3.8.1.tgz#4db2d426f2caa754b12fa4c1264c82ab16618685" + integrity sha512-XYrj3qnTm+o2d5ih5drCq9s63GJoM8vZ26WbLG5FZhURsNxTSXgHJcx11Qo7nWPUStCQkuqk1HvItzscCUnd4A== dependencies: - "@docusaurus/types" "3.8.0" - "@rspack/core" "^1.3.10" + "@docusaurus/types" "3.8.1" + "@rspack/core" "^1.3.15" "@swc/core" "^1.7.39" "@swc/html" "^1.7.39" browserslist "^4.24.2" @@ -2673,22 +2673,22 @@ tslib "^2.6.0" webpack "^5.95.0" -"@docusaurus/logger@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/logger/-/logger-3.8.0.tgz#c1abbb084a8058dc0047d57070fb9cd0241a679d" - integrity sha512-7eEMaFIam5Q+v8XwGqF/n0ZoCld4hV4eCCgQkfcN9Mq5inoZa6PHHW9Wu6lmgzoK5Kx3keEeABcO2SxwraoPDQ== +"@docusaurus/logger@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/logger/-/logger-3.8.1.tgz#45321b2e2e14695d0dbd8b4104ea7b0fbaa98700" + integrity sha512-2wjeGDhKcExEmjX8k1N/MRDiPKXGF2Pg+df/bDDPnnJWHXnVEZxXj80d6jcxp1Gpnksl0hF8t/ZQw9elqj2+ww== dependencies: chalk "^4.1.2" tslib "^2.6.0" -"@docusaurus/mdx-loader@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-3.8.0.tgz#2b225cd2b1159cc49b10b1cac63a927a8368274b" - integrity sha512-mDPSzssRnpjSdCGuv7z2EIAnPS1MHuZGTaRLwPn4oQwszu4afjWZ/60sfKjTnjBjI8Vl4OgJl2vMmfmiNDX4Ng== +"@docusaurus/mdx-loader@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-3.8.1.tgz#74309b3614bbcef1d55fb13e6cc339b7fb000b5f" + integrity sha512-DZRhagSFRcEq1cUtBMo4TKxSNo/W6/s44yhr8X+eoXqCLycFQUylebOMPseHi5tc4fkGJqwqpWJLz6JStU9L4w== dependencies: - "@docusaurus/logger" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/logger" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" "@mdx-js/mdx" "^3.0.0" "@slorber/remark-comment" "^1.0.0" escape-html "^1.0.3" @@ -2711,12 +2711,12 @@ vfile "^6.0.1" webpack "^5.88.1" -"@docusaurus/module-type-aliases@3.8.0", "@docusaurus/module-type-aliases@^3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/module-type-aliases/-/module-type-aliases-3.8.0.tgz#e487052c372538c5dcf2200999e13f328fa5ffaa" - integrity sha512-/uMb4Ipt5J/QnD13MpnoC/A4EYAe6DKNWqTWLlGrqsPJwJv73vSwkA25xnYunwfqWk0FlUQfGv/Swdh5eCCg7g== +"@docusaurus/module-type-aliases@3.8.1", "@docusaurus/module-type-aliases@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/module-type-aliases/-/module-type-aliases-3.8.1.tgz#454de577bd7f50b5eae16db0f76b49ca5e4e281a" + integrity sha512-6xhvAJiXzsaq3JdosS7wbRt/PwEPWHr9eM4YNYqVlbgG1hSK3uQDXTVvQktasp3VO6BmfYWPozueLWuj4gB+vg== dependencies: - "@docusaurus/types" "3.8.0" + "@docusaurus/types" "3.8.1" "@types/history" "^4.7.11" "@types/react" "*" "@types/react-router-config" "*" @@ -2724,19 +2724,19 @@ react-helmet-async "npm:@slorber/react-helmet-async@1.3.0" react-loadable "npm:@docusaurus/react-loadable@6.0.0" -"@docusaurus/plugin-content-blog@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.8.0.tgz#0c200b1fb821e09e9e975c45255e5ddfab06c392" - integrity sha512-0SlOTd9R55WEr1GgIXu+hhTT0hzARYx3zIScA5IzpdekZQesI/hKEa5LPHBd415fLkWMjdD59TaW/3qQKpJ0Lg== +"@docusaurus/plugin-content-blog@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.8.1.tgz#88d842b562b04cf59df900d9f6984b086f821525" + integrity sha512-vNTpMmlvNP9n3hGEcgPaXyvTljanAKIUkuG9URQ1DeuDup0OR7Ltvoc8yrmH+iMZJbcQGhUJF+WjHLwuk8HSdw== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/logger" "3.8.0" - "@docusaurus/mdx-loader" "3.8.0" - "@docusaurus/theme-common" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-common" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/logger" "3.8.1" + "@docusaurus/mdx-loader" "3.8.1" + "@docusaurus/theme-common" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-common" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" cheerio "1.0.0-rc.12" feed "^4.2.2" fs-extra "^11.1.1" @@ -2748,20 +2748,20 @@ utility-types "^3.10.0" webpack "^5.88.1" -"@docusaurus/plugin-content-docs@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.8.0.tgz#6aedb1261da1f0c8c2fa11cfaa6df4577a9b7826" - integrity sha512-fRDMFLbUN6eVRXcjP8s3Y7HpAt9pzPYh1F/7KKXOCxvJhjjCtbon4VJW0WndEPInVz4t8QUXn5QZkU2tGVCE2g== +"@docusaurus/plugin-content-docs@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.8.1.tgz#40686a206abb6373bee5638de100a2c312f112a4" + integrity sha512-oByRkSZzeGNQByCMaX+kif5Nl2vmtj2IHQI2fWjCfCootsdKZDPFLonhIp5s3IGJO7PLUfe0POyw0Xh/RrGXJA== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/logger" "3.8.0" - "@docusaurus/mdx-loader" "3.8.0" - "@docusaurus/module-type-aliases" "3.8.0" - "@docusaurus/theme-common" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-common" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/logger" "3.8.1" + "@docusaurus/mdx-loader" "3.8.1" + "@docusaurus/module-type-aliases" "3.8.1" + "@docusaurus/theme-common" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-common" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" "@types/react-router-config" "^5.0.7" combine-promises "^1.1.0" fs-extra "^11.1.1" @@ -2772,148 +2772,149 @@ utility-types "^3.10.0" webpack "^5.88.1" -"@docusaurus/plugin-content-pages@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.8.0.tgz#2db5f990872684c621665d0d0d8d9b5831fd2999" - integrity sha512-39EDx2y1GA0Pxfion5tQZLNJxL4gq6susd1xzetVBjVIQtwpCdyloOfQBAgX0FylqQxfJrYqL0DIUuq7rd7uBw== +"@docusaurus/plugin-content-pages@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.8.1.tgz#41b684dbd15390b7bb6a627f78bf81b6324511ac" + integrity sha512-a+V6MS2cIu37E/m7nDJn3dcxpvXb6TvgdNI22vJX8iUTp8eoMoPa0VArEbWvCxMY/xdC26WzNv4wZ6y0iIni/w== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/mdx-loader" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/mdx-loader" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" fs-extra "^11.1.1" tslib "^2.6.0" webpack "^5.88.1" -"@docusaurus/plugin-css-cascade-layers@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.8.0.tgz#a0741ae32917a88ce7ce76b6f472495fa4bf576d" - integrity sha512-/VBTNymPIxQB8oA3ZQ4GFFRYdH4ZxDRRBECxyjRyv486mfUPXfcdk+im4S5mKWa6EK2JzBz95IH/Wu0qQgJ5yQ== +"@docusaurus/plugin-css-cascade-layers@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.8.1.tgz#cb414b4a82aa60fc64ef2a435ad0105e142a6c71" + integrity sha512-VQ47xRxfNKjHS5ItzaVXpxeTm7/wJLFMOPo1BkmoMG4Cuz4nuI+Hs62+RMk1OqVog68Swz66xVPK8g9XTrBKRw== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" tslib "^2.6.0" -"@docusaurus/plugin-debug@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-3.8.0.tgz#297c159ae99924e60042426d2ad6ee0d5e9126b3" - integrity sha512-teonJvJsDB9o2OnG6ifbhblg/PXzZvpUKHFgD8dOL1UJ58u0lk8o0ZOkvaYEBa9nDgqzoWrRk9w+e3qaG2mOhQ== +"@docusaurus/plugin-debug@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-3.8.1.tgz#45b107e46b627caaae66995f53197ace78af3491" + integrity sha512-nT3lN7TV5bi5hKMB7FK8gCffFTBSsBsAfV84/v293qAmnHOyg1nr9okEw8AiwcO3bl9vije5nsUvP0aRl2lpaw== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" fs-extra "^11.1.1" react-json-view-lite "^2.3.0" tslib "^2.6.0" -"@docusaurus/plugin-google-analytics@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.8.0.tgz#fb97097af331beb13553a384081dc83607539b31" - integrity sha512-aKKa7Q8+3xRSRESipNvlFgNp3FNPELKhuo48Cg/svQbGNwidSHbZT03JqbW4cBaQnyyVchO1ttk+kJ5VC9Gx0w== +"@docusaurus/plugin-google-analytics@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.8.1.tgz#64a302e62fe5cb6e007367c964feeef7b056764a" + integrity sha512-Hrb/PurOJsmwHAsfMDH6oVpahkEGsx7F8CWMjyP/dw1qjqmdS9rcV1nYCGlM8nOtD3Wk/eaThzUB5TSZsGz+7Q== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" tslib "^2.6.0" -"@docusaurus/plugin-google-gtag@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.8.0.tgz#b5a60006c28ac582859a469fb92e53d383b0a055" - integrity sha512-ugQYMGF4BjbAW/JIBtVcp+9eZEgT9HRdvdcDudl5rywNPBA0lct+lXMG3r17s02rrhInMpjMahN3Yc9Cb3H5/g== +"@docusaurus/plugin-google-gtag@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.8.1.tgz#8c76f8a1d96448f2f0f7b10e6bde451c40672b95" + integrity sha512-tKE8j1cEZCh8KZa4aa80zpSTxsC2/ZYqjx6AAfd8uA8VHZVw79+7OTEP2PoWi0uL5/1Is0LF5Vwxd+1fz5HlKg== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" "@types/gtag.js" "^0.0.12" tslib "^2.6.0" -"@docusaurus/plugin-google-tag-manager@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.8.0.tgz#612aa63e161fb273bf7db2591034c0142951727d" - integrity sha512-9juRWxbwZD3SV02Jd9QB6yeN7eu+7T4zB0bvJLcVQwi+am51wAxn2CwbdL0YCCX+9OfiXbADE8D8Q65Hbopu/w== +"@docusaurus/plugin-google-tag-manager@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.8.1.tgz#88241ffd06369f4a4d5fb982ff3ac2777561ae37" + integrity sha512-iqe3XKITBquZq+6UAXdb1vI0fPY5iIOitVjPQ581R1ZKpHr0qe+V6gVOrrcOHixPDD/BUKdYwkxFjpNiEN+vBw== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" tslib "^2.6.0" -"@docusaurus/plugin-sitemap@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.8.0.tgz#a39e3b5aa2f059aba0052ed11a6b4fbf78ac0dad" - integrity sha512-fGpOIyJvNiuAb90nSJ2Gfy/hUOaDu6826e5w5UxPmbpCIc7KlBHNAZ5g4L4ZuHhc4hdfq4mzVBsQSnne+8Ze1g== +"@docusaurus/plugin-sitemap@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.8.1.tgz#3aebd39186dc30e53023f1aab44625bc0bdac892" + integrity sha512-+9YV/7VLbGTq8qNkjiugIelmfUEVkTyLe6X8bWq7K5qPvGXAjno27QAfFq63mYfFFbJc7z+pudL63acprbqGzw== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/logger" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-common" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/logger" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-common" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" fs-extra "^11.1.1" sitemap "^7.1.1" tslib "^2.6.0" -"@docusaurus/plugin-svgr@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-svgr/-/plugin-svgr-3.8.0.tgz#6d2d43f14b32b4bb2dd8dc87a70c6e78754c1e85" - integrity sha512-kEDyry+4OMz6BWLG/lEqrNsL/w818bywK70N1gytViw4m9iAmoxCUT7Ri9Dgs7xUdzCHJ3OujolEmD88Wy44OA== +"@docusaurus/plugin-svgr@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-svgr/-/plugin-svgr-3.8.1.tgz#6f340be8eae418a2cce540d8ece096ffd9c9b6ab" + integrity sha512-rW0LWMDsdlsgowVwqiMb/7tANDodpy1wWPwCcamvhY7OECReN3feoFwLjd/U4tKjNY3encj0AJSTxJA+Fpe+Gw== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" "@svgr/core" "8.1.0" "@svgr/webpack" "^8.1.0" tslib "^2.6.0" webpack "^5.88.1" -"@docusaurus/preset-classic@^3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/preset-classic/-/preset-classic-3.8.0.tgz#ac8bc17e3b7b443d8a24f2f1da0c0be396950fef" - integrity sha512-qOu6tQDOWv+rpTlKu+eJATCJVGnABpRCPuqf7LbEaQ1mNY//N/P8cHQwkpAU+aweQfarcZ0XfwCqRHJfjeSV/g== +"@docusaurus/preset-classic@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/preset-classic/-/preset-classic-3.8.1.tgz#bb79fd12f3211363720c569a526c7e24d3aa966b" + integrity sha512-yJSjYNHXD8POMGc2mKQuj3ApPrN+eG0rO1UPgSx7jySpYU+n4WjBikbrA2ue5ad9A7aouEtMWUoiSRXTH/g7KQ== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/plugin-content-blog" "3.8.0" - "@docusaurus/plugin-content-docs" "3.8.0" - "@docusaurus/plugin-content-pages" "3.8.0" - "@docusaurus/plugin-css-cascade-layers" "3.8.0" - "@docusaurus/plugin-debug" "3.8.0" - "@docusaurus/plugin-google-analytics" "3.8.0" - "@docusaurus/plugin-google-gtag" "3.8.0" - "@docusaurus/plugin-google-tag-manager" "3.8.0" - "@docusaurus/plugin-sitemap" "3.8.0" - "@docusaurus/plugin-svgr" "3.8.0" - "@docusaurus/theme-classic" "3.8.0" - "@docusaurus/theme-common" "3.8.0" - "@docusaurus/theme-search-algolia" "3.8.0" - "@docusaurus/types" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/plugin-content-blog" "3.8.1" + "@docusaurus/plugin-content-docs" "3.8.1" + "@docusaurus/plugin-content-pages" "3.8.1" + "@docusaurus/plugin-css-cascade-layers" "3.8.1" + "@docusaurus/plugin-debug" "3.8.1" + "@docusaurus/plugin-google-analytics" "3.8.1" + "@docusaurus/plugin-google-gtag" "3.8.1" + "@docusaurus/plugin-google-tag-manager" "3.8.1" + "@docusaurus/plugin-sitemap" "3.8.1" + "@docusaurus/plugin-svgr" "3.8.1" + "@docusaurus/theme-classic" "3.8.1" + "@docusaurus/theme-common" "3.8.1" + "@docusaurus/theme-search-algolia" "3.8.1" + "@docusaurus/types" "3.8.1" -"@docusaurus/theme-classic@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-classic/-/theme-classic-3.8.0.tgz#6d44fb801b86a7c7af01cda0325af1a3300b3ac2" - integrity sha512-nQWFiD5ZjoT76OaELt2n33P3WVuuCz8Dt5KFRP2fCBo2r9JCLsp2GJjZpnaG24LZ5/arRjv4VqWKgpK0/YLt7g== +"@docusaurus/theme-classic@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-classic/-/theme-classic-3.8.1.tgz#1e45c66d89ded359225fcd29bf3258d9205765c1" + integrity sha512-bqDUCNqXeYypMCsE1VcTXSI1QuO4KXfx8Cvl6rYfY0bhhqN6d2WZlRkyLg/p6pm+DzvanqHOyYlqdPyP0iz+iw== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/logger" "3.8.0" - "@docusaurus/mdx-loader" "3.8.0" - "@docusaurus/module-type-aliases" "3.8.0" - "@docusaurus/plugin-content-blog" "3.8.0" - "@docusaurus/plugin-content-docs" "3.8.0" - "@docusaurus/plugin-content-pages" "3.8.0" - "@docusaurus/theme-common" "3.8.0" - "@docusaurus/theme-translations" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-common" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/logger" "3.8.1" + "@docusaurus/mdx-loader" "3.8.1" + "@docusaurus/module-type-aliases" "3.8.1" + "@docusaurus/plugin-content-blog" "3.8.1" + "@docusaurus/plugin-content-docs" "3.8.1" + "@docusaurus/plugin-content-pages" "3.8.1" + "@docusaurus/theme-common" "3.8.1" + "@docusaurus/theme-translations" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-common" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" "@mdx-js/react" "^3.0.0" clsx "^2.0.0" copy-text-to-clipboard "^3.2.0" infima "0.2.0-alpha.45" lodash "^4.17.21" nprogress "^0.2.0" - postcss "^8.4.26" + postcss "^8.5.4" prism-react-renderer "^2.3.0" prismjs "^1.29.0" react-router-dom "^5.3.4" @@ -2921,15 +2922,15 @@ tslib "^2.6.0" utility-types "^3.10.0" -"@docusaurus/theme-common@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-common/-/theme-common-3.8.0.tgz#102c385c3d1d3b7a6b52d1911c7e88c38d9a977e" - integrity sha512-YqV2vAWpXGLA+A3PMLrOMtqgTHJLDcT+1Caa6RF7N4/IWgrevy5diY8oIHFkXR/eybjcrFFjUPrHif8gSGs3Tw== +"@docusaurus/theme-common@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-common/-/theme-common-3.8.1.tgz#17c23316fbe3ee3f7e707c7298cb59a0fff38b4b" + integrity sha512-UswMOyTnPEVRvN5Qzbo+l8k4xrd5fTFu2VPPfD6FcW/6qUtVLmJTQCktbAL3KJ0BVXGm5aJXz/ZrzqFuZERGPw== dependencies: - "@docusaurus/mdx-loader" "3.8.0" - "@docusaurus/module-type-aliases" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-common" "3.8.0" + "@docusaurus/mdx-loader" "3.8.1" + "@docusaurus/module-type-aliases" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-common" "3.8.1" "@types/history" "^4.7.11" "@types/react" "*" "@types/react-router-config" "*" @@ -2939,32 +2940,32 @@ tslib "^2.6.0" utility-types "^3.10.0" -"@docusaurus/theme-mermaid@^3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-mermaid/-/theme-mermaid-3.8.0.tgz#f0720ec89fd386870f30978ba984b1b126ca92a5" - integrity sha512-ou0NJM37p4xrVuFaZp8qFe5Z/qBq9LuyRTP4KKRa0u2J3zC4f3saBJDgc56FyvvN1OsmU0189KGEPUjTr6hFxg== +"@docusaurus/theme-mermaid@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-mermaid/-/theme-mermaid-3.8.1.tgz#2b73b5e90057bd9fb46f267aeb2d3470b168a7c8" + integrity sha512-IWYqjyTPjkNnHsFFu9+4YkeXS7PD1xI3Bn2shOhBq+f95mgDfWInkpfBN4aYvx4fTT67Am6cPtohRdwh4Tidtg== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/module-type-aliases" "3.8.0" - "@docusaurus/theme-common" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/module-type-aliases" "3.8.1" + "@docusaurus/theme-common" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" mermaid ">=11.6.0" tslib "^2.6.0" -"@docusaurus/theme-search-algolia@3.8.0", "@docusaurus/theme-search-algolia@^3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.8.0.tgz#21c2f18e07a73d13ca3b44fcf0ae9aac33bef60f" - integrity sha512-GBZ5UOcPgiu6nUw153+0+PNWvFKweSnvKIL6Rp04H9olKb475jfKjAwCCtju5D2xs5qXHvCMvzWOg5o9f6DtuQ== +"@docusaurus/theme-search-algolia@3.8.1", "@docusaurus/theme-search-algolia@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.8.1.tgz#3aa3d99c35cc2d4b709fcddd4df875a9b536e29b" + integrity sha512-NBFH5rZVQRAQM087aYSRKQ9yGEK9eHd+xOxQjqNpxMiV85OhJDD4ZGz6YJIod26Fbooy54UWVdzNU0TFeUUUzQ== dependencies: "@docsearch/react" "^3.9.0" - "@docusaurus/core" "3.8.0" - "@docusaurus/logger" "3.8.0" - "@docusaurus/plugin-content-docs" "3.8.0" - "@docusaurus/theme-common" "3.8.0" - "@docusaurus/theme-translations" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/logger" "3.8.1" + "@docusaurus/plugin-content-docs" "3.8.1" + "@docusaurus/theme-common" "3.8.1" + "@docusaurus/theme-translations" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" algoliasearch "^5.17.1" algoliasearch-helper "^3.22.6" clsx "^2.0.0" @@ -2974,18 +2975,18 @@ tslib "^2.6.0" utility-types "^3.10.0" -"@docusaurus/theme-translations@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-translations/-/theme-translations-3.8.0.tgz#deb64dccab74361624c3cb352a4949a7ac868c74" - integrity sha512-1DTy/snHicgkCkryWq54fZvsAglTdjTx4qjOXgqnXJ+DIty1B+aPQrAVUu8LiM+6BiILfmNxYsxhKTj+BS3PZg== +"@docusaurus/theme-translations@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-translations/-/theme-translations-3.8.1.tgz#4b1d76973eb53861e167c7723485e059ba4ffd0a" + integrity sha512-OTp6eebuMcf2rJt4bqnvuwmm3NVXfzfYejL+u/Y1qwKhZPrjPoKWfk1CbOP5xH5ZOPkiAsx4dHdQBRJszK3z2g== dependencies: fs-extra "^11.1.1" tslib "^2.6.0" -"@docusaurus/types@3.8.0", "@docusaurus/types@^3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-3.8.0.tgz#f6cd31c4e3e392e0270b8137d7fe4365ea7a022e" - integrity sha512-RDEClpwNxZq02c+JlaKLWoS13qwWhjcNsi2wG1UpzmEnuti/z1Wx4SGpqbUqRPNSd8QWWePR8Cb7DvG0VN/TtA== +"@docusaurus/types@3.8.1", "@docusaurus/types@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-3.8.1.tgz#83ab66c345464e003b576a49f78897482061fc26" + integrity sha512-ZPdW5AB+pBjiVrcLuw3dOS6BFlrG0XkS2lDGsj8TizcnREQg3J8cjsgfDviszOk4CweNfwo1AEELJkYaMUuOPg== dependencies: "@mdx-js/mdx" "^3.0.0" "@types/history" "^4.7.11" @@ -3012,36 +3013,36 @@ webpack "^5.88.1" webpack-merge "^5.9.0" -"@docusaurus/utils-common@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/utils-common/-/utils-common-3.8.0.tgz#2b1a6b1ec4a7fac62f1898d523d42f8cc4a8258f" - integrity sha512-3TGF+wVTGgQ3pAc9+5jVchES4uXUAhAt9pwv7uws4mVOxL4alvU3ue/EZ+R4XuGk94pDy7CNXjRXpPjlfZXQfw== +"@docusaurus/utils-common@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/utils-common/-/utils-common-3.8.1.tgz#c369b8c3041afb7dcd595d4172beb1cc1015c85f" + integrity sha512-zTZiDlvpvoJIrQEEd71c154DkcriBecm4z94OzEE9kz7ikS3J+iSlABhFXM45mZ0eN5pVqqr7cs60+ZlYLewtg== dependencies: - "@docusaurus/types" "3.8.0" + "@docusaurus/types" "3.8.1" tslib "^2.6.0" -"@docusaurus/utils-validation@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-3.8.0.tgz#aa02e9d998e20998fbcaacd94873878bc3b9a4cd" - integrity sha512-MrnEbkigr54HkdFeg8e4FKc4EF+E9dlVwsY3XQZsNkbv3MKZnbHQ5LsNJDIKDROFe8PBf5C4qCAg5TPBpsjrjg== +"@docusaurus/utils-validation@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-3.8.1.tgz#0499c0d151a4098a0963237057993282cfbd538e" + integrity sha512-gs5bXIccxzEbyVecvxg6upTwaUbfa0KMmTj7HhHzc016AGyxH2o73k1/aOD0IFrdCsfJNt37MqNI47s2MgRZMA== dependencies: - "@docusaurus/logger" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-common" "3.8.0" + "@docusaurus/logger" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-common" "3.8.1" fs-extra "^11.2.0" joi "^17.9.2" js-yaml "^4.1.0" lodash "^4.17.21" tslib "^2.6.0" -"@docusaurus/utils@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-3.8.0.tgz#92bad89d2a11f5f246196af153093b12cd79f9ac" - integrity sha512-2wvtG28ALCN/A1WCSLxPASFBFzXCnP0YKCAFIPcvEb6imNu1wg7ni/Svcp71b3Z2FaOFFIv4Hq+j4gD7gA0yfQ== +"@docusaurus/utils@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-3.8.1.tgz#2ac1e734106e2f73dbd0f6a8824d525f9064e9f0" + integrity sha512-P1ml0nvOmEFdmu0smSXOqTS1sxU5tqvnc0dA4MTKV39kye+bhQnjkIKEE18fNOvxjyB86k8esoCIFM3x4RykOQ== dependencies: - "@docusaurus/logger" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils-common" "3.8.0" + "@docusaurus/logger" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils-common" "3.8.1" escape-string-regexp "^4.0.0" execa "5.1.1" file-loader "^6.2.0" @@ -3244,48 +3245,48 @@ dependencies: langium "3.3.1" -"@module-federation/error-codes@0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@module-federation/error-codes/-/error-codes-0.14.0.tgz#d54581bfb998ce9ace4cb33f8795c644e461bfeb" - integrity sha512-GGk+EoeSACJikZZyShnLshtq9E2eCrDWbRiB4QAFXCX4oYmGgFfzXlx59vMNwqTKPJWxkEGnPYacJMcr2YYjag== +"@module-federation/error-codes@0.14.3": + version "0.14.3" + resolved "https://registry.yarnpkg.com/@module-federation/error-codes/-/error-codes-0.14.3.tgz#e6b5c380240f5650bcf67a1906b22271b891d2c5" + integrity sha512-sBJ3XKU9g5Up31jFeXPFsD8AgORV7TLO/cCSMuRewSfgYbG/3vSKLJmfHrO6+PvjZSb9VyV2UaF02ojktW65vw== -"@module-federation/runtime-core@0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@module-federation/runtime-core/-/runtime-core-0.14.0.tgz#a00a3666cc25a8bb822a36552c631e6e3f7326cc" - integrity sha512-fGE1Ro55zIFDp/CxQuRhKQ1pJvG7P0qvRm2N+4i8z++2bgDjcxnCKUqDJ8lLD+JfJQvUJf0tuSsJPgevzueD4g== +"@module-federation/runtime-core@0.14.3": + version "0.14.3" + resolved "https://registry.yarnpkg.com/@module-federation/runtime-core/-/runtime-core-0.14.3.tgz#434025c1304278e30bbc024aaeab086d80f9e196" + integrity sha512-xMFQXflLVW/AJTWb4soAFP+LB4XuhE7ryiLIX8oTyUoBBgV6U2OPghnFljPjeXbud72O08NYlQ1qsHw1kN/V8Q== dependencies: - "@module-federation/error-codes" "0.14.0" - "@module-federation/sdk" "0.14.0" + "@module-federation/error-codes" "0.14.3" + "@module-federation/sdk" "0.14.3" -"@module-federation/runtime-tools@0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@module-federation/runtime-tools/-/runtime-tools-0.14.0.tgz#f5b5f3d19605b6d7c90ed278dc00a5400f1aa49d" - integrity sha512-y/YN0c2DKsLETE+4EEbmYWjqF9G6ZwgZoDIPkaQ9p0pQu0V4YxzWfQagFFxR0RigYGuhJKmSU/rtNoHq+qF8jg== +"@module-federation/runtime-tools@0.14.3": + version "0.14.3" + resolved "https://registry.yarnpkg.com/@module-federation/runtime-tools/-/runtime-tools-0.14.3.tgz#fa1414b449cbe5fb6dcbde4ed02c85e0cdcc758b" + integrity sha512-QBETX7iMYXdSa3JtqFlYU+YkpymxETZqyIIRiqg0gW+XGpH3jgU68yjrme2NBJp7URQi/CFZG8KWtfClk0Pjgw== dependencies: - "@module-federation/runtime" "0.14.0" - "@module-federation/webpack-bundler-runtime" "0.14.0" + "@module-federation/runtime" "0.14.3" + "@module-federation/webpack-bundler-runtime" "0.14.3" -"@module-federation/runtime@0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@module-federation/runtime/-/runtime-0.14.0.tgz#e04012d2d928275fd00525904c0b4f8be514dc70" - integrity sha512-kR3cyHw/Y64SEa7mh4CHXOEQYY32LKLK75kJOmBroLNLO7/W01hMNAvGBYTedS7hWpVuefPk1aFZioy3q2VLdQ== +"@module-federation/runtime@0.14.3": + version "0.14.3" + resolved "https://registry.yarnpkg.com/@module-federation/runtime/-/runtime-0.14.3.tgz#fc9142c093001c67a0fcacaf53d4eb5749e9bbd6" + integrity sha512-7ZHpa3teUDVhraYdxQGkfGHzPbjna4LtwbpudgzAxSLLFxLDNanaxCuSeIgSM9c+8sVUNC9kvzUgJEZB0krPJw== dependencies: - "@module-federation/error-codes" "0.14.0" - "@module-federation/runtime-core" "0.14.0" - "@module-federation/sdk" "0.14.0" + "@module-federation/error-codes" "0.14.3" + "@module-federation/runtime-core" "0.14.3" + "@module-federation/sdk" "0.14.3" -"@module-federation/sdk@0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@module-federation/sdk/-/sdk-0.14.0.tgz#efa38341b7601f58967397cc630068f69691b931" - integrity sha512-lg/OWRsh18hsyTCamOOhEX546vbDiA2O4OggTxxH2wTGr156N6DdELGQlYIKfRdU/0StgtQS81Goc0BgDZlx9A== +"@module-federation/sdk@0.14.3": + version "0.14.3" + resolved "https://registry.yarnpkg.com/@module-federation/sdk/-/sdk-0.14.3.tgz#a03e37f1cb018283542cfc66a87e7a37e39cfe1a" + integrity sha512-THJZMfbXpqjQOLblCQ8jjcBFFXsGRJwUWE9l/Q4SmuCSKMgAwie7yLT0qSGrHmyBYrsUjAuy+xNB4nfKP0pnGw== -"@module-federation/webpack-bundler-runtime@0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.14.0.tgz#21a82505f95fdb3cb202786f8dc20611b4d7f93c" - integrity sha512-POWS6cKBicAAQ3DNY5X7XEUSfOfUsRaBNxbuwEfSGlrkTE9UcWheO06QP2ndHi8tHQuUKcIHi2navhPkJ+k5xg== +"@module-federation/webpack-bundler-runtime@0.14.3": + version "0.14.3" + resolved "https://registry.yarnpkg.com/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.14.3.tgz#2d6bf63e93f626a2f5e469bc57fb5bcc098fee37" + integrity sha512-hIyJFu34P7bY2NeMIUHAS/mYUHEY71VTAsN0A0AqEJFSVPszheopu9VdXq0VDLrP9KQfuXT8SDxeYeJXyj0mgA== dependencies: - "@module-federation/runtime" "0.14.0" - "@module-federation/sdk" "0.14.0" + "@module-federation/runtime" "0.14.3" + "@module-federation/sdk" "0.14.3" "@nodelib/fs.scandir@2.1.5": version "2.1.5" @@ -3469,75 +3470,74 @@ redux-thunk "^2.4.2" reselect "^4.1.8" -"@rspack/binding-darwin-arm64@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.3.12.tgz#2e7cc00b813dcb155572908d956ab1d75d9747a5" - integrity sha512-8hKjVTBeWPqkMzFPNWIh72oU9O3vFy3e88wRjMPImDCXBiEYrKqGTTLd/J0SO+efdL3SBD1rX1IvdJpxCv6Yrw== +"@rspack/binding-darwin-arm64@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.3.15.tgz#8deb8845dbb6285e40dd329b9ad13fcbaf6be8f4" + integrity sha512-f+DnVRENRdVe+ufpZeqTtWAUDSTnP48jVo7x9KWsXf8XyJHUi+eHKEPrFoy1HvL1/k5yJ3HVnFBh1Hb9cNIwSg== -"@rspack/binding-darwin-x64@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.3.12.tgz#cb148cc62658d74204621a695e1698cda82877f9" - integrity sha512-Sj4m+mCUxL7oCpdu7OmWT7fpBM7hywk5CM9RDc3D7StaBZbvNtNftafCrTZzTYKuZrKmemTh5SFzT5Tz7tf6GA== +"@rspack/binding-darwin-x64@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.3.15.tgz#a7dc05a5d278c2c1fd920987afb0b839311bff89" + integrity sha512-TfUvEIBqYUT2OK01BYXb2MNcZeZIhAnJy/5aj0qV0uy4KlvwW63HYcKWa1sFd4Ac7bnGShDkanvP3YEuHOFOyg== -"@rspack/binding-linux-arm64-gnu@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.3.12.tgz#79820857dfbd3819e0026da507c271531c013d0c" - integrity sha512-7MuOxf3/Mhv4mgFdLTvgnt/J+VouNR65DEhorth+RZm3LEWojgoFEphSAMAvpvAOpYSS68Sw4SqsOZi719ia2w== +"@rspack/binding-linux-arm64-gnu@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.3.15.tgz#31a29d4aac66fd92232bccbb2be0273362e1d6f2" + integrity sha512-D/YjYk9snKvYm1Elotq8/GsEipB4ZJWVv/V8cZ+ohhFNOPzygENi6JfyI06TryBTQiN0/JDZqt/S9RaWBWnMqw== -"@rspack/binding-linux-arm64-musl@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.3.12.tgz#a794dfe8df6bf75af217597881592add6f6b046e" - integrity sha512-s6KKj20T9Z1bA8caIjU6EzJbwyDo1URNFgBAlafCT2UC6yX7flstDJJ38CxZacA9A2P24RuQK2/jPSZpWrTUFA== +"@rspack/binding-linux-arm64-musl@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.3.15.tgz#f8bdc956f6ef644b1c469d20ecc3849415f3e71b" + integrity sha512-lJbBsPMOiR0hYPCSM42yp7QiZjfo0ALtX7ws2wURpsQp3BMfRVAmXU3Ixpo2XCRtG1zj8crHaCmAWOJTS0smsA== -"@rspack/binding-linux-x64-gnu@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.3.12.tgz#a0e23831a1374d9039a4b29e927cef58a485085c" - integrity sha512-0w/sRREYbRgHgWvs2uMEJSLfvzbZkPHUg6CMcYQGNVK6axYRot6jPyKetyFYA9pR5fB5rsXegpnFaZaVrRIK2g== +"@rspack/binding-linux-x64-gnu@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.3.15.tgz#5dd39fd59eb5d3f8e353110f3ed40e00242c73f8" + integrity sha512-qGB8ucHklrzNg6lsAS36VrBsCbOw0acgpQNqTE5cuHWrp1Pu3GFTRiFEogenxEmzoRbohMZt0Ev5grivrcgKBQ== -"@rspack/binding-linux-x64-musl@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.3.12.tgz#61620efda6a6805689890c130e6b38c1725baaf4" - integrity sha512-jEdxkPymkRxbijDRsBGdhopcbGXiXDg59lXqIRkVklqbDmZ/O6DHm7gImmlx5q9FoWbz0gqJuOKBz4JqWxjWVA== +"@rspack/binding-linux-x64-musl@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.3.15.tgz#32c2bb197568cca841a26ecea019590bc0e2ce56" + integrity sha512-qRn6e40fLQP+N2rQD8GAj/h4DakeTIho32VxTIaHRVuzw68ZD7VmKkwn55ssN370ejmey35ZdoNFNE12RBrMZA== -"@rspack/binding-win32-arm64-msvc@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.3.12.tgz#46d874df8bd5b84e82ea83480969f7e0293ccd82" - integrity sha512-ZRvUCb3TDLClAqcTsl/o9UdJf0B5CgzAxgdbnYJbldyuyMeTUB4jp20OfG55M3C2Nute2SNhu2bOOp9Se5Ongw== +"@rspack/binding-win32-arm64-msvc@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.3.15.tgz#75c1b04d2ea08b49f480825ad57d8ca82acaf6d9" + integrity sha512-7uJ7dWhO1nWXJiCss6Rslz8hoAxAhFpwpbWja3eHgRb7O4NPHg6MWw63AQSI2aFVakreenfu9yXQqYfpVWJ2dA== -"@rspack/binding-win32-ia32-msvc@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.3.12.tgz#fec435e31e56f3d58b4fa52746643d1d593b2b89" - integrity sha512-1TKPjuXStPJr14f3ZHuv40Xc/87jUXx10pzVtrPnw+f3hckECHrbYU/fvbVzZyuXbsXtkXpYca6ygCDRJAoNeQ== +"@rspack/binding-win32-ia32-msvc@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.3.15.tgz#56093d8818d68cd270ba63bddffd38dbd50957f7" + integrity sha512-UsaWTYCjDiSCB0A0qETgZk4QvhwfG8gCrO4SJvA+QSEWOmgSai1YV70prFtLLIiyT9mDt1eU3tPWl1UWPRU/EQ== -"@rspack/binding-win32-x64-msvc@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.3.12.tgz#33b73cbab75920cf8a92e7245a794970f8508b6c" - integrity sha512-lCR0JfnYKpV+a6r2A2FdxyUKUS4tajePgpPJN5uXDgMGwrDtRqvx+d0BHhwjFudQVJq9VVbRaL89s2MQ6u+xYw== +"@rspack/binding-win32-x64-msvc@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.3.15.tgz#eb404ff76ea9da045173e6efccc6b7f276fd6960" + integrity sha512-ZnDIc9Es8EF94MirPDN+hOMt7tkb8nMEbRJFKLMmNd0ElNPgsql+1cY5SqyGRH1hsKB87KfSUQlhFiKZvzbfIg== -"@rspack/binding@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding/-/binding-1.3.12.tgz#0a8356fdbd89f08cda3e9bb8aff4ea781dfe972e" - integrity sha512-4Ic8lV0+LCBfTlH5aIOujIRWZOtgmG223zC4L3o8WY/+ESAgpdnK6lSSMfcYgRanYLAy3HOmFIp20jwskMpbAg== +"@rspack/binding@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding/-/binding-1.3.15.tgz#535fa1f14d173fb72a2d8bb7df10906827e77185" + integrity sha512-utNPuJglLO5lW9XbwIqjB7+2ilMo6JkuVLTVdnNVKU94FW7asn9F/qV+d+MgjUVqU1QPCGm0NuGO9xhbgeJ7pg== optionalDependencies: - "@rspack/binding-darwin-arm64" "1.3.12" - "@rspack/binding-darwin-x64" "1.3.12" - "@rspack/binding-linux-arm64-gnu" "1.3.12" - "@rspack/binding-linux-arm64-musl" "1.3.12" - "@rspack/binding-linux-x64-gnu" "1.3.12" - "@rspack/binding-linux-x64-musl" "1.3.12" - "@rspack/binding-win32-arm64-msvc" "1.3.12" - "@rspack/binding-win32-ia32-msvc" "1.3.12" - "@rspack/binding-win32-x64-msvc" "1.3.12" + "@rspack/binding-darwin-arm64" "1.3.15" + "@rspack/binding-darwin-x64" "1.3.15" + "@rspack/binding-linux-arm64-gnu" "1.3.15" + "@rspack/binding-linux-arm64-musl" "1.3.15" + "@rspack/binding-linux-x64-gnu" "1.3.15" + "@rspack/binding-linux-x64-musl" "1.3.15" + "@rspack/binding-win32-arm64-msvc" "1.3.15" + "@rspack/binding-win32-ia32-msvc" "1.3.15" + "@rspack/binding-win32-x64-msvc" "1.3.15" -"@rspack/core@^1.3.10": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/core/-/core-1.3.12.tgz#68df0111cfac7e8f9dfa11a608ac8731181b5483" - integrity sha512-mAPmV4LPPRgxpouUrGmAE4kpF1NEWJGyM5coebsjK/zaCMSjw3mkdxiU2b5cO44oIi0Ifv5iGkvwbdrZOvMyFA== +"@rspack/core@^1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/core/-/core-1.3.15.tgz#22bce959aa386c3f38021af6ee6a94339896ffd2" + integrity sha512-QuElIC8jXSKWAp0LSx18pmbhA7NiA5HGoVYesmai90UVxz98tud0KpMxTVCg+0lrLrnKZfCWN9kwjCxM5pGnrA== dependencies: - "@module-federation/runtime-tools" "0.14.0" - "@rspack/binding" "1.3.12" + "@module-federation/runtime-tools" "0.14.3" + "@rspack/binding" "1.3.15" "@rspack/lite-tapable" "1.0.1" - caniuse-lite "^1.0.30001718" "@rspack/lite-tapable@1.0.1": version "1.0.1" @@ -5163,7 +5163,7 @@ browserslist@^4.0.0, browserslist@^4.21.10, browserslist@^4.22.2, browserslist@^ node-releases "^2.0.14" update-browserslist-db "^1.0.16" -browserslist@^4.24.0, browserslist@^4.24.2, browserslist@^4.24.4, browserslist@^4.24.5: +browserslist@^4.24.0, browserslist@^4.24.2, browserslist@^4.24.4: version "4.24.5" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.5.tgz#aa0f5b8560fe81fde84c6dcb38f759bafba0e11b" integrity sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw== @@ -5173,6 +5173,16 @@ browserslist@^4.24.0, browserslist@^4.24.2, browserslist@^4.24.4, browserslist@^ node-releases "^2.0.19" update-browserslist-db "^1.1.3" +browserslist@^4.25.0: + version "4.25.0" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.25.0.tgz#986aa9c6d87916885da2b50d8eb577ac8d133b2c" + integrity sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA== + dependencies: + caniuse-lite "^1.0.30001718" + electron-to-chromium "^1.5.160" + node-releases "^2.0.19" + update-browserslist-db "^1.1.3" + buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" @@ -5445,7 +5455,7 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== -clean-css@^5.2.2, clean-css@^5.3.2, clean-css@~5.3.2: +clean-css@^5.2.2, clean-css@^5.3.3, clean-css@~5.3.2: version "5.3.3" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.3.tgz#b330653cd3bd6b75009cc25c714cae7b93351ccd" integrity sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg== @@ -5834,7 +5844,7 @@ css-has-pseudo@^7.0.2: postcss-selector-parser "^7.0.0" postcss-value-parser "^4.2.0" -css-loader@^6.8.1: +css-loader@^6.11.0: version "6.11.0" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.11.0.tgz#33bae3bf6363d0a7c2cf9031c96c744ff54d85ba" integrity sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g== @@ -6665,6 +6675,11 @@ electron-to-chromium@^1.5.149: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.158.tgz#e5f01fc7fdf810d9d223e30593e0839c306276d4" integrity sha512-9vcp2xHhkvraY6AHw2WMi+GDSLPX42qe2xjYaVoZqFRJiOcilVQFq9mZmpuHEQpzlgGDelKlV7ZiGcmMsc8WxQ== +electron-to-chromium@^1.5.160: + version "1.5.172" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.172.tgz#fe1d99028d8d6321668d0f1fed61d99ac896259c" + integrity sha512-fnKW9dGgmBfsebbYognQSv0CGGLFH1a5iV9EDYTBwmAQn+whbzHbLFlC+3XbHc8xaNtpO0etm8LOcRXs1qMRkQ== + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" @@ -10053,7 +10068,7 @@ mimic-response@^4.0.0: resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-4.0.0.tgz#35468b19e7c75d10f5165ea25e75a5ceea7cf70f" integrity sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg== -mini-css-extract-plugin@^2.9.1: +mini-css-extract-plugin@^2.9.2: version "2.9.2" resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz#966031b468917a5446f4c24a80854b2947503c5b" integrity sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w== @@ -10159,6 +10174,11 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + nanoid@^3.3.7: version "3.3.8" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" @@ -10842,10 +10862,10 @@ postcss-custom-media@^11.0.6: "@csstools/css-tokenizer" "^3.0.4" "@csstools/media-query-list-parser" "^4.0.3" -postcss-custom-properties@^14.0.5: - version "14.0.5" - resolved "https://registry.yarnpkg.com/postcss-custom-properties/-/postcss-custom-properties-14.0.5.tgz#a180444de695f6e11ee2390be93ff6537663e86c" - integrity sha512-UWf/vhMapZatv+zOuqlfLmYXeOhhHLh8U8HAKGI2VJ00xLRYoAJh4xv8iX6FB6+TLXeDnm0DBLMi00E0hodbQw== +postcss-custom-properties@^14.0.6: + version "14.0.6" + resolved "https://registry.yarnpkg.com/postcss-custom-properties/-/postcss-custom-properties-14.0.6.tgz#1af73a650bf115ba052cf915287c9982825fc90e" + integrity sha512-fTYSp3xuk4BUeVhxCSJdIPhDLpJfNakZKoiTDx7yRGCdlZrSJR7mWKVOBS4sBF+5poPQFMj2YdXx1VHItBGihQ== dependencies: "@csstools/cascade-layer-name-parser" "^2.0.5" "@csstools/css-parser-algorithms" "^3.0.5" @@ -10973,7 +10993,7 @@ postcss-load-config@^4.0.1: lilconfig "^3.0.0" yaml "^2.3.4" -postcss-loader@^7.3.3: +postcss-loader@^7.3.4: version "7.3.4" resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-7.3.4.tgz#aed9b79ce4ed7e9e89e56199d25ad1ec8f606209" integrity sha512-iW5WTTBSC5BfsBJ9daFMPVrLT36MrNiC6fqOZTTaHjBNX6Pfd5p+hSBqe/fEeNd7pc13QiAyGt7VdGMw4eRC4A== @@ -11082,12 +11102,12 @@ postcss-nested@^6.0.1: dependencies: postcss-selector-parser "^6.0.11" -postcss-nesting@^13.0.1: - version "13.0.1" - resolved "https://registry.yarnpkg.com/postcss-nesting/-/postcss-nesting-13.0.1.tgz#c405796d7245a3e4c267a9956cacfe9670b5d43e" - integrity sha512-VbqqHkOBOt4Uu3G8Dm8n6lU5+9cJFxiuty9+4rcoyRPO9zZS1JIs6td49VIoix3qYqELHlJIn46Oih9SAKo+yQ== +postcss-nesting@^13.0.2: + version "13.0.2" + resolved "https://registry.yarnpkg.com/postcss-nesting/-/postcss-nesting-13.0.2.tgz#fde0d4df772b76d03b52eccc84372e8d1ca1402e" + integrity sha512-1YCI290TX+VP0U/K/aFxzHzQWHWURL+CtHMSbex1lCdpXD1SoR2sYuxDu5aNI9lPoXpKTCggFZiDJbwylU0LEQ== dependencies: - "@csstools/selector-resolve-nested" "^3.0.0" + "@csstools/selector-resolve-nested" "^3.1.0" "@csstools/selector-specificity" "^5.0.0" postcss-selector-parser "^7.0.0" @@ -11185,10 +11205,10 @@ postcss-place@^10.0.0: dependencies: postcss-value-parser "^4.2.0" -postcss-preset-env@^10.1.0: - version "10.2.0" - resolved "https://registry.yarnpkg.com/postcss-preset-env/-/postcss-preset-env-10.2.0.tgz#ea95a6fc70efb1a26f81e5cf0ccdb321a3439b6e" - integrity sha512-cl13sPBbSqo1Q7Ryb19oT5NZO5IHFolRbIMdgDq4f9w1MHYiL6uZS7uSsjXJ1KzRIcX5BMjEeyxmAevVXENa3Q== +postcss-preset-env@^10.2.1: + version "10.2.3" + resolved "https://registry.yarnpkg.com/postcss-preset-env/-/postcss-preset-env-10.2.3.tgz#3a84bde7205b48f1304a656b25841bd3f40fb3cb" + integrity sha512-zlQN1yYmA7lFeM1wzQI14z97mKoM8qGng+198w1+h6sCud/XxOjcKtApY9jWr7pXNS3yHDEafPlClSsWnkY8ow== dependencies: "@csstools/postcss-cascade-layers" "^5.0.1" "@csstools/postcss-color-function" "^4.0.10" @@ -11202,7 +11222,7 @@ postcss-preset-env@^10.1.0: "@csstools/postcss-hwb-function" "^4.0.10" "@csstools/postcss-ic-unit" "^4.0.2" "@csstools/postcss-initial" "^2.0.1" - "@csstools/postcss-is-pseudo-class" "^5.0.1" + "@csstools/postcss-is-pseudo-class" "^5.0.3" "@csstools/postcss-light-dark-function" "^2.0.9" "@csstools/postcss-logical-float-and-clear" "^3.0.0" "@csstools/postcss-logical-overflow" "^2.0.0" @@ -11224,7 +11244,7 @@ postcss-preset-env@^10.1.0: "@csstools/postcss-trigonometric-functions" "^4.0.9" "@csstools/postcss-unset-value" "^4.0.0" autoprefixer "^10.4.21" - browserslist "^4.24.5" + browserslist "^4.25.0" css-blank-pseudo "^7.0.1" css-has-pseudo "^7.0.2" css-prefers-color-scheme "^10.0.0" @@ -11235,7 +11255,7 @@ postcss-preset-env@^10.1.0: postcss-color-hex-alpha "^10.0.0" postcss-color-rebeccapurple "^10.0.0" postcss-custom-media "^11.0.6" - postcss-custom-properties "^14.0.5" + postcss-custom-properties "^14.0.6" postcss-custom-selectors "^8.0.5" postcss-dir-pseudo-class "^9.0.1" postcss-double-position-gradients "^6.0.2" @@ -11246,7 +11266,7 @@ postcss-preset-env@^10.1.0: postcss-image-set-function "^7.0.0" postcss-lab-function "^7.0.10" postcss-logical "^8.1.0" - postcss-nesting "^13.0.1" + postcss-nesting "^13.0.2" postcss-opacity-percentage "^3.0.0" postcss-overflow-shorthand "^6.0.0" postcss-page-break "^3.0.4" @@ -11344,7 +11364,7 @@ postcss-zindex@^6.0.2: resolved "https://registry.yarnpkg.com/postcss-zindex/-/postcss-zindex-6.0.2.tgz#e498304b83a8b165755f53db40e2ea65a99b56e1" integrity sha512-5BxW9l1evPB/4ZIc+2GobEBoKC+h8gPGCMi+jxsYvd2x0mjq7wazk6DrP71pStqxE9Foxh5TVnonbWpFZzXaYg== -postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.24, postcss@^8.4.26, postcss@^8.4.31, postcss@^8.4.33, postcss@^8.4.38: +postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.24, postcss@^8.4.31, postcss@^8.4.33: version "8.4.38" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== @@ -11353,6 +11373,15 @@ postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.24, postcss@^8.4.26, postcss@^8.4 picocolors "^1.0.0" source-map-js "^1.2.0" +postcss@^8.5.4: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + postman-code-generators@^1.10.1: version "1.10.1" resolved "https://registry.yarnpkg.com/postman-code-generators/-/postman-code-generators-1.10.1.tgz#5d8d8500616b2bb0cac7417e923c36b2e73cbffe" @@ -12682,6 +12711,11 @@ sort-css-media-queries@2.2.0: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + source-map-support@~0.5.20: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" From 5da5ccda5ced787f28addbc88f087082e2fd1a39 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:53:40 +0200 Subject: [PATCH 06/66] fix: correct user v2 api docs for v3 (#10112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved As documentation is published from the main branch and the releases get created from another branch, they are not always correctly equal. # How the Problems Are Solved Remove the unnecessary changes in the documentation for now, and create a second PR which can then be used to update the documentation. # Additional Changes Correct integration tests which also use the endpoints. # Additional Context Closes #10083 --------- Co-authored-by: Fabienne Bühler --- proto/zitadel/management.proto | 44 +- proto/zitadel/user/v2/user_service.proto | 544 ++++++++++------------- 2 files changed, 240 insertions(+), 348 deletions(-) diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 8cd0b22759..d633fbe8c5 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -493,8 +493,6 @@ service ManagementService { // Create User (Machine) // - // Deprecated: use [user service v2 CreateUser](apis/resources/user_service_v2/user-service-create-user.api.mdx) to create a user of type machine instead. - // // Create a new user with the type machine for your API, service or device. These users are used for non-interactive authentication flows. rpc AddMachineUser(AddMachineUserRequest) returns (AddMachineUserResponse) { option (google.api.http) = { @@ -507,7 +505,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -693,8 +690,6 @@ service ManagementService { // Change user name // - // Deprecated: use [user service v2 UpdateUser](apis/resources/user_service_v2/user-service-update-user.api.mdx) instead. - // // Change the username of the user. Be aware that the user has to log in with the newly added username afterward rpc UpdateUserName(UpdateUserNameRequest) returns (UpdateUserNameResponse) { option (google.api.http) = { @@ -708,7 +703,6 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Users"; - deprecated: true; responses: { key: "200" value: { @@ -1124,8 +1118,6 @@ service ManagementService { // Update User Phone (Human) // - // Deprecated: use [user service v2 SetPhone](apis/resources/user_service_v2/user-service-update-user.api.mdx) instead. - // // Change the phone number of a user. If the state is set to not verified, the user will get an SMS to verify (if a notification provider is configured). The phone number is only for informational purposes and to send messages, not for Authentication (2FA). rpc UpdateHumanPhone(UpdateHumanPhoneRequest) returns (UpdateHumanPhoneResponse) { option (google.api.http) = { @@ -1140,7 +1132,6 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Users"; tags: "User Human"; - deprecated: true; responses: { key: "200" value: { @@ -1656,8 +1647,6 @@ service ManagementService { // Update Machine User // - // Deprecated: use [user service v2 UpdateUser](apis/resources/user_service_v2/user-service-update-user.api.mdx) to update a user of type machine instead. - // // Change a service account/machine user. It is used for accounts with non-interactive authentication possibilities. rpc UpdateMachine(UpdateMachineRequest) returns (UpdateMachineResponse) { option (google.api.http) = { @@ -1670,7 +1659,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1692,8 +1680,6 @@ service ManagementService { // Create Secret for Machine User // - // Deprecated: use [user service v2 AddSecret](apis/resources/user_service_v2/user-service-add-secret.api.mdx) instead. - // // Create a new secret for a machine user/service account. It is used to authenticate the user (client credential grant). rpc GenerateMachineSecret(GenerateMachineSecretRequest) returns (GenerateMachineSecretResponse) { option (google.api.http) = { @@ -1706,7 +1692,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1728,8 +1713,6 @@ service ManagementService { // Delete Secret of Machine User // - // Deprecated: use [user service v2 RemoveSecret](apis/resources/user_service_v2/user-service-remove-secret.api.mdx) instead. - // // Delete a secret of a machine user/service account. The user will not be able to authenticate with the secret afterward. rpc RemoveMachineSecret(RemoveMachineSecretRequest) returns (RemoveMachineSecretResponse) { option (google.api.http) = { @@ -1741,7 +1724,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1763,8 +1745,6 @@ service ManagementService { // Get Machine user Key By ID // - // Deprecated: use [user service v2 ListKeys](apis/resources/user_service_v2/user-service-list-keys.api.mdx) instead. - // // Get a specific Key of a machine user by its id. Machine keys are used to authenticate with jwt profile authentication. rpc GetMachineKeyByIDs(GetMachineKeyByIDsRequest) returns (GetMachineKeyByIDsResponse) { option (google.api.http) = { @@ -1776,7 +1756,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1796,9 +1775,7 @@ service ManagementService { }; } - // Get Machine user Key By ID - // - // Deprecated: use [user service v2 ListKeys](apis/resources/user_service_v2/user-service-list-keys.api.mdx) instead. + // List Machine Keys // // Get the list of keys of a machine user. Machine keys are used to authenticate with jwt profile authentication. rpc ListMachineKeys(ListMachineKeysRequest) returns (ListMachineKeysResponse) { @@ -1812,7 +1789,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1834,8 +1810,6 @@ service ManagementService { // Create Key for machine user // - // Deprecated: use [user service v2 AddKey](apis/resources/user_service_v2/user-service-add-key.api.mdx) instead. - // // If a public key is not supplied, a new key is generated and will be returned in the response. // Make sure to store the returned key. // If an RSA public key is supplied, the private key is omitted from the response. @@ -1851,7 +1825,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1873,8 +1846,6 @@ service ManagementService { // Delete Key for machine user // - // Deprecated: use [user service v2 RemoveKey](apis/resources/user_service_v2/user-service-remove-key.api.mdx) instead. - // // Delete a specific key from a user. // The user will not be able to authenticate with that key afterward. rpc RemoveMachineKey(RemoveMachineKeyRequest) returns (RemoveMachineKeyResponse) { @@ -1887,7 +1858,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1909,8 +1879,6 @@ service ManagementService { // Get Personal-Access-Token (PAT) by ID // - // Deprecated: use [user service v2 ListPersonalAccessTokens](apis/resources/user_service_v2/user-service-list-personal-access-tokens.api.mdx) instead. - // // Returns the PAT for a user, currently only available for machine users/service accounts. PATs are ready-to-use tokens and can be sent directly in the authentication header. rpc GetPersonalAccessTokenByIDs(GetPersonalAccessTokenByIDsRequest) returns (GetPersonalAccessTokenByIDsResponse) { option (google.api.http) = { @@ -1922,7 +1890,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1944,8 +1911,6 @@ service ManagementService { // List Personal-Access-Tokens (PATs) // - // Deprecated: use [user service v2 ListPersonalAccessTokens](apis/resources/user_service_v2/user-service-list-personal-access-tokens.api.mdx) instead. - // // Returns a list of PATs for a user, currently only available for machine users/service accounts. PATs are ready-to-use tokens and can be sent directly in the authentication header. rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse) { option (google.api.http) = { @@ -1958,7 +1923,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1980,8 +1944,6 @@ service ManagementService { // Create a Personal-Access-Token (PAT) // - // Deprecated: use [user service v2 AddPersonalAccessToken](apis/resources/user_service_v2/user-service-add-personal-access-token.api.mdx) instead. - // // Generates a new PAT for the user. Currently only available for machine users. // The token will be returned in the response, make sure to store it. // PATs are ready-to-use tokens and can be sent directly in the authentication header. @@ -1996,7 +1958,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -2018,8 +1979,6 @@ service ManagementService { // Remove a Personal-Access-Token (PAT) by ID // - // Deprecated: use [user service v2 RemovePersonalAccessToken](apis/resources/user_service_v2/user-service-remove-personal-access-token.api.mdx) instead. - // // Delete a PAT from a user. Afterward, the user will not be able to authenticate with that token anymore. rpc RemovePersonalAccessToken(RemovePersonalAccessTokenRequest) returns (RemovePersonalAccessTokenResponse) { option (google.api.http) = { @@ -2031,7 +1990,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index a416555905..79f66266bc 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -133,13 +133,6 @@ service UserService { // Required permission: // - user.write rpc CreateUser (CreateUserRequest) returns (CreateUserResponse) { - option (google.api.http) = { - // The /new path segment does not follow Zitadels API design. - // The only reason why it is used here is to avoid a conflict with the ListUsers endpoint, which already handles POST /v2/users. - post: "/v2/users/new" - body: "*" - }; - option (zitadel.protoc_gen_zitadel.v2.options) = { auth_option: { permission: "authenticated" @@ -169,8 +162,6 @@ service UserService { // Create a new human user // - // Deprecated: Use [CreateUser](apis/resources/user_service_v2/user-service-create-user.api.mdx) to create a new user of type human instead. - // // Create/import a new user with the type human. The newly created user will get a verification email if either the email address is not marked as verified and you did not request the verification to be returned. rpc AddHumanUser (AddHumanUserRequest) returns (AddHumanUserResponse) { option (google.api.http) = { @@ -189,7 +180,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -270,8 +260,6 @@ service UserService { // Change the user email // - // Deprecated: [Update the users email field](apis/resources/user_service_v2/user-service-update-user.api.mdx). - // // Change the email address of a user. If the state is set to not verified, a verification code will be generated, which can be either returned or sent to the user by email.. rpc SetEmail (SetEmailRequest) returns (SetEmailResponse) { option (google.api.http) = { @@ -286,7 +274,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -393,8 +380,6 @@ service UserService { // Set the user phone // - // Deprecated: [Update the users phone field](apis/resources/user_service_v2/user-service-update-user.api.mdx). - // // Set the phone number of a user. If the state is set to not verified, a verification code will be generated, which can be either returned or sent to the user by sms.. rpc SetPhone(SetPhoneRequest) returns (SetPhoneResponse) { option (google.api.http) = { @@ -409,7 +394,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -427,8 +411,6 @@ service UserService { // Delete the user phone // - // Deprecated: [Update the users phone field](apis/resources/user_service_v2/user-service-update-user.api.mdx) to remove the phone number. - // // Delete the phone number of a user. rpc RemovePhone(RemovePhoneRequest) returns (RemovePhoneResponse) { option (google.api.http) = { @@ -443,7 +425,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -519,7 +500,6 @@ service UserService { }; } - // Update a User // // Partially update an existing user. @@ -529,10 +509,6 @@ service UserService { // Required permission: // - user.write rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse) { - option (google.api.http) = { - patch: "/v2/users/{user_id}" - body: "*" - }; option (zitadel.protoc_gen_zitadel.v2.options) = { auth_option: { @@ -574,8 +550,6 @@ service UserService { // Update Human User // - // Deprecated: Use [UpdateUser](apis/resources/user_service_v2/user-service-update-user.api.mdx) to update a user of type human instead. - // // Update all information from a user.. rpc UpdateHumanUser(UpdateHumanUserRequest) returns (UpdateHumanUserResponse) { option (google.api.http) = { @@ -590,7 +564,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -1378,8 +1351,6 @@ service UserService { // Change password // - // Deprecated: [Update the users password](apis/resources/user_service_v2/user-service-update-user.api.mdx) instead. - // // Change the password of a user with either a verification code or the current password.. rpc SetPassword (SetPasswordRequest) returns (SetPasswordResponse) { option (google.api.http) = { @@ -1394,7 +1365,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -1410,6 +1380,245 @@ service UserService { }; } + + // Add a Users Secret + // + // Generates a client secret for the user. + // The client id is the users username. + // If the user already has a secret, it is overwritten. + // Only users of type machine can have a secret. + // + // Required permission: + // - user.write + rpc AddSecret(AddSecretRequest) returns (AddSecretResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The secret was successfully generated."; + } + }; + responses: { + key: "404" + value: { + description: "The user ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Remove a Users Secret + // + // Remove the current client ID and client secret from a machine user. + // + // Required permission: + // - user.write + rpc RemoveSecret(RemoveSecretRequest) returns (RemoveSecretResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The secret was either successfully removed or it didn't exist in the first place."; + } + }; + }; + } + + // Add a Key + // + // Add a keys that can be used to securely authenticate at the Zitadel APIs using JWT profile authentication using short-lived tokens. + // Make sure you store the returned key safely, as you won't be able to read it from the Zitadel API anymore. + // Only users of type machine can have keys. + // + // Required permission: + // - user.write + rpc AddKey(AddKeyRequest) returns (AddKeyResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The key was successfully created."; + } + }; + responses: { + key: "404" + value: { + description: "The user ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Remove a Key + // + // Remove a machine users key by the given key ID and an optionally given user ID. + // + // Required permission: + // - user.write + rpc RemoveKey(RemoveKeyRequest) returns (RemoveKeyResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The key was either successfully removed or it not found in the first place."; + } + }; + }; + } + + // Search Keys + // + // List all matching keys. By default all keys of the instance on which the caller has permission to read the owning users are returned. + // Make sure to include a limit and sorting for pagination. + // + // Required permission: + // - user.read + rpc ListKeys(ListKeysRequest) returns (ListKeysResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all machine user keys matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } + + // Add a Personal Access Token + // + // Personal access tokens (PAT) are the easiest way to authenticate to the Zitadel APIs. + // Make sure you store the returned PAT safely, as you won't be able to read it from the Zitadel API anymore. + // Only users of type machine can have personal access tokens. + // + // Required permission: + // - user.write + rpc AddPersonalAccessToken(AddPersonalAccessTokenRequest) returns (AddPersonalAccessTokenResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The personal access token was successfully created."; + } + }; + responses: { + key: "404" + value: { + description: "The user ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Remove a Personal Access Token + // + // Removes a machine users personal access token by the given token ID and an optionally given user ID. + // + // Required permission: + // - user.write + rpc RemovePersonalAccessToken(RemovePersonalAccessTokenRequest) returns (RemovePersonalAccessTokenResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The personal access token was either successfully removed or it was not found in the first place."; + } + }; + }; + } + + // Search Personal Access Tokens + // + // List all personal access tokens. By default all personal access tokens of the instance on which the caller has permission to read the owning users are returned. + // Make sure to include a limit and sorting for pagination. + // + // Required permission: + // - user.read + rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all personal access tokens matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } + // List all possible authentication methods of a user // // List all possible authentication methods of a user like password, passwordless, (T)OTP and more.. @@ -1584,281 +1793,6 @@ service UserService { } }; } - - // Add a Users Secret - // - // Generates a client secret for the user. - // The client id is the users username. - // If the user already has a secret, it is overwritten. - // Only users of type machine can have a secret. - // - // Required permission: - // - user.write - rpc AddSecret(AddSecretRequest) returns (AddSecretResponse) { - option (google.api.http) = { - post: "/v2/users/{user_id}/secret" - body: "*" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The secret was successfully generated."; - } - }; - responses: { - key: "404" - value: { - description: "The user ID does not exist."; - schema: { - json_schema: { - ref: "#/definitions/rpcStatus"; - }; - }; - } - }; - }; - } - - // Remove a Users Secret - // - // Remove the current client ID and client secret from a machine user. - // - // Required permission: - // - user.write - rpc RemoveSecret(RemoveSecretRequest) returns (RemoveSecretResponse) { - option (google.api.http) = { - delete: "/v2/users/{user_id}/secret" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The secret was either successfully removed or it didn't exist in the first place."; - } - }; - }; - } - - // Add a Key - // - // Add a keys that can be used to securely authenticate at the Zitadel APIs using JWT profile authentication using short-lived tokens. - // Make sure you store the returned key safely, as you won't be able to read it from the Zitadel API anymore. - // Only users of type machine can have keys. - // - // Required permission: - // - user.write - rpc AddKey(AddKeyRequest) returns (AddKeyResponse) { - option (google.api.http) = { - post: "/v2/users/{user_id}/keys" - body: "*" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The key was successfully created."; - } - }; - responses: { - key: "404" - value: { - description: "The user ID does not exist."; - schema: { - json_schema: { - ref: "#/definitions/rpcStatus"; - }; - }; - } - }; - }; - } - - // Remove a Key - // - // Remove a machine users key by the given key ID and an optionally given user ID. - // - // Required permission: - // - user.write - rpc RemoveKey(RemoveKeyRequest) returns (RemoveKeyResponse) { - option (google.api.http) = { - delete: "/v2/users/{user_id}/keys/{key_id}" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The key was either successfully removed or it not found in the first place."; - } - }; - }; - } - - // Search Keys - // - // List all matching keys. By default all keys of the instance on which the caller has permission to read the owning users are returned. - // Make sure to include a limit and sorting for pagination. - // - // Required permission: - // - user.read - rpc ListKeys(ListKeysRequest) returns (ListKeysResponse) { - option (google.api.http) = { - post: "/v2/users/keys/search" - body: "*" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200"; - value: { - description: "A list of all machine user keys matching the query"; - }; - }; - responses: { - key: "400"; - value: { - description: "invalid list query"; - }; - }; - }; - } - - // Add a Personal Access Token - // - // Personal access tokens (PAT) are the easiest way to authenticate to the Zitadel APIs. - // Make sure you store the returned PAT safely, as you won't be able to read it from the Zitadel API anymore. - // Only users of type machine can have personal access tokens. - // - // Required permission: - // - user.write - rpc AddPersonalAccessToken(AddPersonalAccessTokenRequest) returns (AddPersonalAccessTokenResponse) { - option (google.api.http) = { - post: "/v2/users/{user_id}/pats" - body: "*" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The personal access token was successfully created."; - } - }; - responses: { - key: "404" - value: { - description: "The user ID does not exist."; - schema: { - json_schema: { - ref: "#/definitions/rpcStatus"; - }; - }; - } - }; - }; - } - - // Remove a Personal Access Token - // - // Removes a machine users personal access token by the given token ID and an optionally given user ID. - // - // Required permission: - // - user.write - rpc RemovePersonalAccessToken(RemovePersonalAccessTokenRequest) returns (RemovePersonalAccessTokenResponse) { - option (google.api.http) = { - delete: "/v2/users/{user_id}/pats/{token_id}" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The personal access token was either successfully removed or it was not found in the first place."; - } - }; - }; - } - - // Search Personal Access Tokens - // - // List all personal access tokens. By default all personal access tokens of the instance on which the caller has permission to read the owning users are returned. - // Make sure to include a limit and sorting for pagination. - // - // Required permission: - // - user.read - rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse) { - option (google.api.http) = { - post: "/v2/users/pats/search" - body: "*" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200"; - value: { - description: "A list of all personal access tokens matching the query"; - }; - }; - responses: { - key: "400"; - value: { - description: "invalid list query"; - }; - }; - }; - } } message AddHumanUserRequest{ From 27f88a639035ca83be099c7a35b2ebe48f486b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Racedo?= Date: Wed, 25 Jun 2025 12:44:11 -0300 Subject: [PATCH 07/66] docs(migration): Added step-by-step guide for the Auth0 to Zitadel migration (#10118) Added a step-by-step guide for the Auth0 to Zitadel migration in preparation for the upcoming workshop. --- .../guides/migrate/sources/auth0-guide.md | 185 ++++++++++++++++++ docs/sidebars.js | 1 + 2 files changed, 186 insertions(+) create mode 100644 docs/docs/guides/migrate/sources/auth0-guide.md diff --git a/docs/docs/guides/migrate/sources/auth0-guide.md b/docs/docs/guides/migrate/sources/auth0-guide.md new file mode 100644 index 0000000000..f8a62b75fa --- /dev/null +++ b/docs/docs/guides/migrate/sources/auth0-guide.md @@ -0,0 +1,185 @@ +--- +title: Migrating Users from Auth0 to ZITADEL (Including Password Hashes) +sidebar_label: Auth0 Migration Guide +--- + +## 1. Introduction + +This guide will walk you through the steps to migrate users from Auth0 to ZITADEL, including password hashes (which requires Auth0's support assistance), so users don't need to reset their passwords. + +**What you'll learn with this guide** +- How to prepare your data from Auth0 +- Use of the ZITADEL migration tooling +- Performing the user import via ZITADEL's API +- Troubleshooting and validating the migration + +--- + +## 2. Prerequisites + +### 2.1. Install Go +The migration tool is written in Go. Download and install the latest version of Go from the [official Go website](https://go.dev/doc/install). + +### 2.2. Create a ZITADEL Instance and Organization +You'll need a target organization in ZITADEL to import your users. You can create a new organization or use an existing one. + +If you don't have a ZITADEL instance, you can [sign up for free here](https://zitadel.com) to create a new one for you. +See: [Managing Organizations in ZITADEL](https://zitadel.com/docs/guides/manage/console/organizations). + +> **Note:** Copy your Organization ID (Resource ID) since you will use the id in the later steps. + +--- + +## 3. Preparing Auth0 Data + +### 3.1. Export User Profiles and Password Hashes from Auth0 +You cannot bulk export user data from the Auth0 Dashboard. Instead, use the [Auth0 Management API](https://auth0.com/docs/manage-users/user-migration#bulk-user-exports) or the [User Import/Export extension](https://auth0.com/docs/manage-users/user-migration/user-import-export-extension). + +> **Important:** Password hashes cannot be obtained in a self-service way. +> You must open a **support ticket** with Auth0 and request a password hash export. +> If approved, Auth0 will provide an export containing the password hashes. + +Reference: [Export hashed passwords from Auth0](https://zitadel.com/docs/guides/migrate/sources/auth0#export-hashed-passwords) + +--- + +## 4. Running the ZITADEL Migration Tool + +### 4.1. Install the Migration Tool +Follow the installation instructions to set up the ZITADEL migration tool from [ZITADEL Tools](https://github.com/zitadel/zitadel-tools?tab=readme-ov-file#installation). + +### 4.2. Generate Import JSON +Use the migration tool to convert the Auth0 export file to a ZITADEL-compatible JSON. +Step-by-step instructions: [Migration Tool for Auth0](https://github.com/zitadel/zitadel-tools/blob/main/cmd/migration/auth0/readme.md) + +Typical steps: +- Run the migration tool with your exported Auth0 files as input. +- The tool generates a JSON file ready for import into ZITADEL. + +Example: +After obtaining the 2 required input files (passwords and profile) in JSON lines format, you can run the following command: + +Sample `passwords.ndjson` content, as obtained from the Auth0 Support team: +```json +{"_id":{"$oid":"emxdpVxozXeFb1HeEn5ThAK8"},"email_verified":true,"email":"tommie_krajcik85@hotmail.com","passwordHash":"$2b$10$d.GvZhGwTllA7OdAmsA75uGGzqr/mhdQoU88M3zD.fX3Vb8Rcf33.","password_set_date":{"$date":"2025-06-30T00:00:00.000Z"},"tenant":"test","connection":"Username-Password-Authentication","_tmp_is_unique":true} +``` + +Sample `profiles.json` content, as obtained from the Auth0 Management API: +```json +{"user_id":"auth0|emxdpVxozXeFb1HeEn5ThAK8","email_verified":true,"name":"Tommie Krajcik","email":"tommie_krajcik85@hotmail.com"} +``` + +Run the following command in your terminal (replace ORG_ID with your own organization ID): +```bash +zitadel-tools migrate auth0 --org= --users=./profiles.json --passwords=./passwords.ndjson --multiline --email-verified --output=./importBody.json --timeout=5m0s +``` + +The tool will merge both objects into a single one in the importBody.json output, this will be used in the next step to complete the import process. + +## 5. Importing Users into ZITADEL + +### 5.1. Obtain Access Token (or PAT) for API Access + +To call the ZITADEL Admin API, you need to authenticate using a **Service User** with the `IAM_OWNER` Manager permissions. + +There are two recommended authentication methods: + +- **Client Credentials Flow** + [Learn how to authenticate with client credentials.](https://zitadel.com/docs/guides/integrate/service-users/client-credentials) + +- **Personal Access Token (PAT)** + [Learn how to create and use a PAT.](https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users#personal-access-token) + +**Reference:** [Service Users & API Authentication](https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users#authentication-methods) + +--- + +### 5.2. Import Data with the ZITADEL API + +- Use your **access token** or **PAT** to authenticate. +- Call the [Admin API – Import Data](https://zitadel.com/docs/apis/resources/admin/admin-service-import-data) endpoint, passing your generated JSON file. +- Verify that the users were imported successfully in the ZITADEL console. + +**Import Endpoint:** + +- `POST /admin/v1/import` +- `Authorization: Bearer ` +- **Body:** Generated in step 4.2 + +#### Example cURL request + +```bash +curl --location 'https:///admin/v1/import' \ +--header 'Content-Type: application/json' \ +--header 'Accept: application/json' \ +--header 'Authorization: Bearer ' \ +--data-raw '{ + "dataOrgs": { + "orgs": [ + { + "orgId": "", + "humanUsers": [ + { + "userId": "auth0|emxdpVxozXeFb1HeEn5ThAK8", + "user": { + "userName": "tommie_krajcik85@hotmail.com", + "profile": { + "firstName": "Tommie Krajcik", + "lastName": "Tommie Krajcik" + }, + "email": { + "email": "tommie_krajcik85@hotmail.com", + "isEmailVerified": true + }, + "hashedPassword": { + "value": "$2b$10$d.GvZhGwTllA7OdAmsA75uGGzqr/mhdQoU88M3zD.fX3Vb8Rcf33." + } + } + } + ] + } + ] + }, + "timeout": "5m0s" +}' +``` + +## 6. Testing the Migration + +### 6.1. Test User Login + +Use the **ZITADEL login page** or your integrated app to test logging in with one of the imported users. + +> **Password for the sample user:** `Password1!` + +Confirm that the migrated password works as expected. + +--- + +### 6.2. Troubleshooting + +**Common issues:** + +- Missing password hashes +- Malformed JSON +- Invalid or incomplete user data + +The import endpoint returns an `errors` array which can help you identify any issues with the import. + +#### Where to check logs and get help + +You can also verify that a user was imported by calling the **events endpoint** and checking for the following event type: + +```json +"user.human.added" +``` + +## 7. Q&A and Further Resources + +### Real-World Scenarios & Common Questions + +**Q:** What is the maximum number of users that can be imported in a single batch? +**A:** There is no hard limit on the number of users. However, there is a **timeout**. +For **ZITADEL Cloud deployments**, the timeout is **5 minutes**, which typically allows for importing around **5,000 users per batch**. + +--- \ No newline at end of file diff --git a/docs/sidebars.js b/docs/sidebars.js index d7ebb80f5b..fe77ea0af2 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -203,6 +203,7 @@ module.exports = { items: [ "guides/migrate/sources/zitadel", "guides/migrate/sources/auth0", + "guides/migrate/sources/auth0-guide", "guides/migrate/sources/keycloak", ], }, From 1ebbe275b98d1b0918b808e1d7960df1ad638c27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Thu, 26 Jun 2025 11:08:37 +0300 Subject: [PATCH 08/66] chore(oidc): remove legacy storage methods (#10061) # Which Problems Are Solved Stabilize the optimized introspection code and cleanup unused code. # How the Problems Are Solved - `oidc_legacy_introspection` feature flag is removed and reserved. - `OPStorage` which are no longer needed have their bodies removed. - The method definitions need to remain in place so the interface remains implemented. - A panic is thrown in case any such method is still called # Additional Changes - A number of `OPStorage` methods related to token creation were already unused. These are also cleaned up. # Additional Context - Closes #10027 - #7822 --------- Co-authored-by: Livio Spring --- cmd/setup/config_test.go | 1 - cmd/start/config_test.go | 1 - .../components/features/features.component.ts | 1 - console/src/assets/i18n/bg.json | 2 - console/src/assets/i18n/cs.json | 2 - console/src/assets/i18n/de.json | 2 - console/src/assets/i18n/en.json | 2 - console/src/assets/i18n/es.json | 2 - console/src/assets/i18n/fr.json | 2 - console/src/assets/i18n/hu.json | 2 - console/src/assets/i18n/id.json | 2 - console/src/assets/i18n/it.json | 2 - console/src/assets/i18n/ja.json | 2 - console/src/assets/i18n/ko.json | 2 - console/src/assets/i18n/mk.json | 2 - console/src/assets/i18n/nl.json | 2 - console/src/assets/i18n/pl.json | 2 - console/src/assets/i18n/pt.json | 2 - console/src/assets/i18n/ro.json | 2 - console/src/assets/i18n/ru.json | 2 - console/src/assets/i18n/sv.json | 2 - console/src/assets/i18n/zh.json | 2 - docs/docs/apis/openidoauth/scopes.md | 8 +- internal/api/grpc/feature/v2/converter.go | 4 - .../api/grpc/feature/v2/converter_test.go | 20 - .../v2/integration_test/feature_test.go | 16 +- internal/api/grpc/feature/v2beta/converter.go | 4 - .../api/grpc/feature/v2beta/converter_test.go | 20 - .../v2beta/integration_test/feature_test.go | 9 - internal/api/oidc/auth_request.go | 49 +- internal/api/oidc/client.go | 832 +----------------- internal/api/oidc/client_credentials.go | 21 - internal/api/oidc/device_auth.go | 4 +- .../integration_test/token_exchange_test.go | 7 - .../oidc/integration_test/userinfo_test.go | 15 +- internal/api/oidc/introspect.go | 3 - internal/api/oidc/jwt-profile.go | 33 +- internal/api/oidc/userinfo.go | 3 - internal/command/instance_features.go | 2 - internal/command/instance_features_model.go | 5 - internal/command/instance_features_test.go | 32 +- internal/command/project_application_api.go | 31 - .../command/project_application_api_test.go | 101 --- internal/command/project_application_oidc.go | 31 - .../command/project_application_oidc_test.go | 170 ---- internal/command/system_features.go | 2 - internal/command/system_features_model.go | 5 - internal/command/system_features_test.go | 32 +- internal/feature/feature.go | 33 +- internal/feature/feature_test.go | 1 - internal/feature/key_enumer.go | 123 +-- internal/query/app.go | 92 -- internal/query/authn_key.go | 48 - internal/query/authn_key_test.go | 49 -- internal/query/instance_features.go | 1 - internal/query/instance_features_model.go | 7 +- internal/query/instance_features_test.go | 28 - .../query/projection/instance_features.go | 4 - .../projection/instance_features_test.go | 4 +- internal/query/projection/system_features.go | 4 - .../query/projection/system_features_test.go | 4 +- internal/query/system_features.go | 1 - internal/query/system_features_model.go | 6 +- internal/query/system_features_test.go | 24 - internal/query/user_grant.go | 8 - .../feature/feature_v2/eventstore.go | 2 - .../repository/feature/feature_v2/feature.go | 2 - proto/zitadel/feature/v2/instance.proto | 21 +- proto/zitadel/feature/v2/system.proto | 22 +- proto/zitadel/feature/v2beta/instance.proto | 21 +- proto/zitadel/feature/v2beta/system.proto | 22 +- 71 files changed, 143 insertions(+), 1884 deletions(-) diff --git a/cmd/setup/config_test.go b/cmd/setup/config_test.go index 8cf241cd6a..b147ed54a7 100644 --- a/cmd/setup/config_test.go +++ b/cmd/setup/config_test.go @@ -48,7 +48,6 @@ Actions: want: func(t *testing.T, config *Config) { assert.Equal(t, config.DefaultInstance.Features, &command.InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), - LegacyIntrospection: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(true), UserSchema: gu.Ptr(true), }) diff --git a/cmd/start/config_test.go b/cmd/start/config_test.go index 53c95d35ab..918fa51950 100644 --- a/cmd/start/config_test.go +++ b/cmd/start/config_test.go @@ -85,7 +85,6 @@ Actions: want: func(t *testing.T, config *Config) { assert.Equal(t, config.DefaultInstance.Features, &command.InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), - LegacyIntrospection: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(true), UserSchema: gu.Ptr(true), }) diff --git a/console/src/app/components/features/features.component.ts b/console/src/app/components/features/features.component.ts index d95bbdde43..70e038bae8 100644 --- a/console/src/app/components/features/features.component.ts +++ b/console/src/app/components/features/features.component.ts @@ -33,7 +33,6 @@ const FEATURE_KEYS = [ 'enableBackChannelLogout', // 'improvedPerformance', 'loginDefaultOrg', - 'oidcLegacyIntrospection', 'oidcSingleV1SessionTermination', 'oidcTokenExchange', 'oidcTriggerIntrospectionProjections', diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index dc3dc04193..7d594e8318 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1623,8 +1623,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Организация по подразбиране за влизане", "LOGINDEFAULTORG_DESCRIPTION": "Потребителският интерфейс за влизане ще използва настройките на организацията по подразбиране (а не на инстанцията), ако не е зададен контекст на организация.", - "OIDCLEGACYINTROSPECTION": "Наследено осмисляне OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "На скоро префакторизирахме крайния пункт за осмисляне заради производителностни причини. Тази функция може да се използва за връщане към наследената реализация, ако възникнат неочаквани грешки.", "OIDCTOKENEXCHANGE": "Обмяна на токени OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Активиране на експерименталния тип на дарение urn:ietf:params:oauth:grant-type:token-exchange за краен пункт на токен OIDC. Обменът на токени може да се използва за заявка на токени с по-малък обхват или за имперсонализиране на други потребители. Вижте политиката за сигурност, за да разрешите имперсонализацията на инстанция.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Тригери за проекции на осмисляне на OIDC", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 6c85389c60..2ee5d9d0c5 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Výchozí organizace pro přihlášení", "LOGINDEFAULTORG_DESCRIPTION": "Přihlašovací rozhraní použije nastavení výchozí organizace (a ne z instance), pokud není nastaven žádný kontext organizace.", - "OIDCLEGACYINTROSPECTION": "Dědictví OIDC introspekce", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Nedávno jsme přepracovali bod introspekce z výkonnostních důvodů. Tato funkce lze použít k rollbacku na dědickou implementaci, pokud se objeví neočekávané chyby.", "OIDCTOKENEXCHANGE": "Výměna tokenů OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Povolit experimentální typ udělení urn:ietf:params:oauth:grant-type:token-exchange pro bod tokenového bodu OIDC. Výměna tokenů lze použít k žádosti o tokeny s menším rozsahem nebo k impersonaci jiných uživatelů. Podívejte se na bezpečnostní politiku, abyste umožnili impersonaci na instanci.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Spouštěče projekcí introspekce OIDC", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index a1f449c1c2..b8f8363d13 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Standardorganisation für die Anmeldung", "LOGINDEFAULTORG_DESCRIPTION": "Die Anmelde-Benutzeroberfläche verwendet die Einstellungen der Standardorganisation (und nicht von der Instanz), wenn kein Organisationskontext festgelegt ist.", - "OIDCLEGACYINTROSPECTION": "OIDC Legacy-Introspektion", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Wir haben kürzlich den Introspektionsendpunkt aus Leistungsgründen neu strukturiert. Mit diesem Feature können Sie zur alten Implementierung zurückkehren, falls unerwartete Fehler auftreten.", "OIDCTOKENEXCHANGE": "OIDC Token-Austausch", "OIDCTOKENEXCHANGE_DESCRIPTION": "Aktivieren Sie den experimentellen urn:ietf:params:oauth:grant-type:token-exchange-Grant-Typ für den OIDC-Token-Endpunkt. Der Token-Austausch kann verwendet werden, um Token mit einem geringeren Umfang anzufordern oder andere Benutzer zu impersonieren. Siehe die Sicherheitsrichtlinie, um die Impersonation auf einer Instanz zu erlauben.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Trigger-Introspektionsprojektionen", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 9e8dadd416..fe152acb81 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1627,8 +1627,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Login Default Org", "LOGINDEFAULTORG_DESCRIPTION": "The login UI will use the settings of the default org (and not from the instance) if no organization context is set", - "OIDCLEGACYINTROSPECTION": "OIDC Legacy introspection", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise.", "OIDCTOKENEXCHANGE": "OIDC Token Exchange", "OIDCTOKENEXCHANGE_DESCRIPTION": "Enable the experimental urn:ietf:params:oauth:grant-type:token-exchange grant type for the OIDC token endpoint. Token exchange can be used to request tokens with a lesser scope or impersonate other users. See the security policy to allow impersonation on an instance.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Trigger introspection Projections", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index d5493f5d70..fff111fd1d 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1625,8 +1625,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Organización predeterminada de inicio de sesión", "LOGINDEFAULTORG_DESCRIPTION": "La interfaz de inicio de sesión utilizará la configuración de la organización predeterminada (y no de la instancia) si no se establece ningún contexto de organización.", - "OIDCLEGACYINTROSPECTION": "Introspección heredada OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Recientemente hemos refactorizado el punto de introspección por razones de rendimiento. Esta función se puede utilizar para volver a la implementación heredada si surgen errores inesperados.", "OIDCTOKENEXCHANGE": "Intercambio de tokens OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Habilita el tipo de concesión experimental urn:ietf:params:oauth:grant-type:token-exchange para el punto de extremo de token OIDC. El intercambio de tokens se puede utilizar para solicitar tokens con un alcance menor o suplantar a otros usuarios. Consulta la política de seguridad para permitir la suplantación en una instancia.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Desencadenadores de proyecciones de introspección OIDC", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index c0a17ac19d..fc5cf69602 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Organisation par défaut de connexion", "LOGINDEFAULTORG_DESCRIPTION": "L'interface de connexion utilisera les paramètres de l'organisation par défaut (et non de l'instance) si aucun contexte d'organisation n'est défini.", - "OIDCLEGACYINTROSPECTION": "Introspection héritée OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Nous avons récemment refondu le point d'introspection pour des raisons de performances. Cette fonctionnalité peut être utilisée pour revenir à l'implémentation héritée en cas de bogues inattendus.", "OIDCTOKENEXCHANGE": "Échange de jetons OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Activez le type d'octroi expérimental urn:ietf:params:oauth:grant-type:token-exchange pour le point de terminaison de jeton OIDC. L'échange de jetons peut être utilisé pour demander des jetons avec une portée moindre ou pour usurper d'autres utilisateurs. Consultez la politique de sécurité pour autoriser l'usurpation sur une instance.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Déclencheurs de projections d'introspection OIDC", diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index 2f208b82b9..d7dd32b15a 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -1622,8 +1622,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Alapértelmezett Org bejelentkezés", "LOGINDEFAULTORG_DESCRIPTION": "A bejelentkezési felület az alapértelmezett org beállításait fogja használni (és nem az instance-tól), ha nincs megadva szervezeti kontextus", - "OIDCLEGACYINTROSPECTION": "OIDC régi introspekció", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Nemrég refaktoráltuk az introspekciós végpontot a teljesítmény javítása érdekében. Ezt a funkciót használhatod a régi implementációra való visszaállításhoz, ha váratlan hibák lépnének fel.", "OIDCTOKENEXCHANGE": "OIDC Token Exchange", "OIDCTOKENEXCHANGE_DESCRIPTION": "Engedélyezd a kísérleti urn:ietf:params:oauth:grant-type:token-exchange támogatását az OIDC token végpont számára. A token csere használható kisebb hatókörű tokenek kérésére vagy más felhasználók megszemélyesítésére. Tekintsd meg a biztonsági irányelvet az impersonáció engedélyezéséhez egy példányon.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Introspekciós Projekciók Indítása", diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index d1d4001813..3dcd7b36b7 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -1498,8 +1498,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Masuk Organisasi Default", "LOGINDEFAULTORG_DESCRIPTION": "UI login akan menggunakan pengaturan organisasi default (dan bukan dari instance) jika tidak ada konteks organisasi yang ditetapkan", - "OIDCLEGACYINTROSPECTION": "Introspeksi Warisan OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Kami baru-baru ini memfaktorkan ulang titik akhir introspeksi untuk alasan kinerja. Fitur ini dapat digunakan untuk melakukan rollback ke implementasi lama jika muncul bug yang tidak terduga.", "OIDCTOKENEXCHANGE": "Pertukaran Token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Aktifkan jenis pemberian urn:ietf:params:oauth:grant-type:token-exchange eksperimental untuk titik akhir token OIDC. Pertukaran token dapat digunakan untuk meminta token dengan cakupan yang lebih kecil atau menyamar sebagai pengguna lain. Lihat kebijakan keamanan untuk mengizinkan peniruan identitas pada sebuah instans.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Memicu Proyeksi Introspeksi", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index 2645afb801..da1df2ba38 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Organizzazione predefinita per l'accesso", "LOGINDEFAULTORG_DESCRIPTION": "L'interfaccia di accesso utilizzerà le impostazioni dell'organizzazione predefinita (e non dell'istanza) se non è impostato alcun contesto organizzativo.", - "OIDCLEGACYINTROSPECTION": "Introspezione legacy OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Abbiamo recentemente ristrutturato il punto di introspezione per motivi di prestazioni. Questa funzionalità può essere utilizzata per tornare alla vecchia implementazione in caso di bug imprevisti.", "OIDCTOKENEXCHANGE": "Scambio token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Abilita il tipo di concessione sperimentale urn:ietf:params:oauth:grant-type:token-exchange per il punto finale del token OIDC. Lo scambio di token può essere utilizzato per richiedere token con uno scopo inferiore o impersonare altri utenti. Consultare la policy di sicurezza per consentire l'impersonificazione su un'istanza.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Proiezioni trigger OIDC per l'introspezione", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 95855ca5fe..1828a8ada8 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "ログイン時の既定組織", "LOGINDEFAULTORG_DESCRIPTION": "組織コンテキストが設定されていない場合、ログイン UI は既定の組織の設定を使用します (インスタンスの設定ではなく)", - "OIDCLEGACYINTROSPECTION": "OIDC レガシーイントロスペクション", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "パフォーマンス上の理由から最近イントロスペクション エンドポイントをリファクタリングしました。この機能は、予期しないバグが発生した場合にレガシー実装にロールバックするために使用できます。", "OIDCTOKENEXCHANGE": "OIDC トークン交換", "OIDCTOKENEXCHANGE_DESCRIPTION": "OIDC トークン エンドポイント用に実験的な urn:ietf:params:oauth:grant-type:token-exchange 付与タイプを有効にします。トークン交換は、より少ないスコープを持つトークンを要求するか、他のユーザーになりすますために使用できます。インスタンスでのなりすましを許可するには、セキュリティポリシーを参照してください。", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC トリガーイントロスペクションプロジェクション", diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index b51f14ff20..af5fa65972 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "로그인 기본 조직", "LOGINDEFAULTORG_DESCRIPTION": "조직 컨텍스트가 설정되지 않은 경우 로그인 UI가 기본 조직의 설정을 사용합니다 (인스턴스에서 설정되지 않음).", - "OIDCLEGACYINTROSPECTION": "OIDC 레거시 내부 조사", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "최근 내부 조사 엔드포인트를 성능을 위해 리팩토링했습니다. 예상치 못한 버그가 발생하면 이 기능을 사용하여 레거시 구현으로 롤백할 수 있습니다.", "OIDCTOKENEXCHANGE": "OIDC 토큰 교환", "OIDCTOKENEXCHANGE_DESCRIPTION": "OIDC 토큰 엔드포인트의 실험적 urn:ietf:params:oauth:grant-type:token-exchange 허용을 활성화합니다. 토큰 교환을 통해 범위가 좁은 토큰을 요청하거나 다른 사용자를 가장할 수 있습니다. 인스턴스에서 가장을 허용하는 보안 정책을 확인하세요.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC 트리거 내부 조사 프로젝션", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index c89a78238d..1e85e06928 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1625,8 +1625,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Најава Стандардна организација", "LOGINDEFAULTORG_DESCRIPTION": "Интерфејсот за најавување ќе ги користи поставките на стандардната организација (а не од примерот) ако не е поставен контекст на организацијата", - "OIDCLEGACYINTROSPECTION": "Интроспекција на наследството на OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Неодамна ја рефакториравме крајната точка на интроспекција поради перформанси. Оваа функција може да се користи за враќање на наследната имплементација доколку се појават неочекувани грешки.", "OIDCTOKENEXCHANGE": "Размена на токени OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Овозможете го експерименталниот тип на грант urn:ietf:params:oauth:grant-type:token-exchange за крајната точка на токенот OIDC. Размената на токени може да се користи за барање токени со помал опсег или имитирање на други корисници. Погледнете ја безбедносната политика за да дозволите имитирање на пример.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Проекции за интроспекција на активирањето на OIDC", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index 17eb2241f7..c3de881784 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Standaard inlogorganisatie", "LOGINDEFAULTORG_DESCRIPTION": "Als er geen organisatiecontext is ingesteld, gebruikt de inlog-UI de instellingen van de standaardorganisatie (en niet van de instantie)", - "OIDCLEGACYINTROSPECTION": "Oude OIDC-introspectie", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "We hebben onlangs het introspectie-endpoint opnieuw gefactoreerd omwille van de prestaties. Deze functie kan worden gebruikt om terug te keren naar de oude implementatie als er onverwachte bugs optreden.", "OIDCTOKENEXCHANGE": "OIDC-tokenuitwisseling", "OIDCTOKENEXCHANGE_DESCRIPTION": "Schakel het experimentele type verlening urn:ietf:params:oauth:grant-type:token-exchange in voor het OIDC-tokenendpoint. Tokenuitwisseling kan worden gebruikt om tokens met een kleinere scope op te vragen of om zich voor te doen als andere gebruikers. Raadpleeg het beveiligingsbeleid om impersonation op een instantie toe te staan.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC-triggers voor introspectieprojecties", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 33e5f5291d..ca5476463d 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1623,8 +1623,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Domyślna Organizacja Logowania", "LOGINDEFAULTORG_DESCRIPTION": "Jeśli nie ustawiono kontekstu organizacji, interfejs logowania będzie używać ustawień domyślnej organizacji (a nie instancji)", - "OIDCLEGACYINTROSPECTION": "Starsza Introspekcja OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Ostatnio przeprojektowaliśmy punkt końcowy introspekcji ze względów wydajnościowych. Ta funkcja może być używana do cofnięcia do starszej implementacji, jeśli wystąpią nieoczekiwane błędy.", "OIDCTOKENEXCHANGE": "Wymiana Tokenów OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Włącz eksperymentalny typ grantu urn:ietf:params:oauth:grant-type:token-exchange dla punktu końcowego tokena OIDC. Wymiana tokenów może być używana do żądania tokenów o mniejszym zakresie lub podszywania się za innych użytkowników. Aby zezwolić na podszywanie się na instancji, zapoznaj się z polityką bezpieczeństwa.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Projekcje Introspekcji Wyzwalane przez OIDC", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index ccd17170e4..7c68a4cada 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1625,8 +1625,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Organização Padrão de Login", "LOGINDEFAULTORG_DESCRIPTION": "A interface de login utilizará as configurações da organização padrão (e não da instância) se nenhum contexto de organização estiver definido", - "OIDCLEGACYINTROSPECTION": "Introspecção Legada OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Recentemente refatoramos o endpoint de introspecção por motivos de performance. Esse recurso pode ser usado para reverter para a implementação legada caso surjam bugs inesperados.", "OIDCTOKENEXCHANGE": "Troca de Token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Habilita o tipo de concessão experimental urn:ietf:params:oauth:grant-type:token-exchange para o endpoint de token OIDC. A troca de token pode ser usada para solicitar tokens com escopo menor ou personificar outros usuários. Consulte a política de segurança para permitir a personificação em uma instância.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Projeções de Introspecção com Gatilho OIDC", diff --git a/console/src/assets/i18n/ro.json b/console/src/assets/i18n/ro.json index e0f2e93045..0e0802a17c 100644 --- a/console/src/assets/i18n/ro.json +++ b/console/src/assets/i18n/ro.json @@ -1622,8 +1622,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Organizație implicită de conectare", "LOGINDEFAULTORG_DESCRIPTION": "UI-ul de conectare va utiliza setările organizației implicite (și nu din instanță) dacă nu este setat niciun context de organizație", - "OIDCLEGACYINTROSPECTION": "Introspecție OIDC Legacy", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Am refactorizat recent endpointul de introspecție din motive de performanță. Această caracteristică poate fi utilizată pentru a reveni la implementarea legacy dacă apar erori neașteptate.", "OIDCTOKENEXCHANGE": "Schimb de token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Activați tipul de grant experimental urn:ietf:params:oauth:grant-type:token-exchange pentru endpointul token OIDC. Schimbul de tokenuri poate fi utilizat pentru a solicita tokenuri cu o rază de acțiune mai mică sau pentru a impersona alți utilizatori. Consultați politica de securitate pentru a permite impersonarea pe o instanță.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Proiecții de introspecție OIDC Trigger", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 175e18688b..8e06568a82 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -1677,8 +1677,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Организация по умолчанию для входа", "LOGINDEFAULTORG_DESCRIPTION": "Если контекст организации не установлен, пользовательский интерфейс входа будет использовать настройки организации по умолчанию (а не экземпляра)", - "OIDCLEGACYINTROSPECTION": "Устаревшая интроспекция OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Недавно мы переработали конечную точку интроспекции для повышения производительности. Эта функция может использоваться для отката к устаревшей реализации, если возникнут непредвиденные ошибки.", "OIDCTOKENEXCHANGE": "Обмен токенами OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Включите экспериментальный тип гранта urn:ietf:params:oauth:grant-type:token-exchange для конечной точки токена OIDC. Обмен токенами можно использовать для запроса токенов с меньшей областью действия или для impersonation (выдачи себя за) других пользователей. Информацию о разрешении impersonation на экземпляре см. в политике безопасности.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Проекции интроспекции с триггером OIDC", diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 9de5093353..1b80021a67 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -1628,8 +1628,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Standardorganisation för inloggning", "LOGINDEFAULTORG_DESCRIPTION": "Inloggningsgränssnittet kommer att använda inställningarna för standardorganisationen (och inte från instansen) om ingen organisationskontext är inställd", - "OIDCLEGACYINTROSPECTION": "OIDC Legacy introspection", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Vi har nyligen omarbetat introspektionsändpunkten av prestandaskäl. Denna funktion kan användas för att återgå till den äldre implementationen om oväntade buggar uppstår.", "OIDCTOKENEXCHANGE": "OIDC Token Exchange", "OIDCTOKENEXCHANGE_DESCRIPTION": "Aktivera den experimentella urn:ietf:params:oauth:grant-type:token-exchange grant-typen för OIDC-tokenändpunkten. Tokenutbyte kan användas för att begära tokens med en mindre omfattning eller impersonera andra användare. Se säkerhetspolicyn för att tillåta impersonation på en instans.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Trigger introspection Projections", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 4aa5ad0ef2..9565b61eca 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "登录默认组织", "LOGINDEFAULTORG_DESCRIPTION": "如果没有设置组织上下文,登录界面将使用默认组织的设置(而不是实例的设置)", - "OIDCLEGACYINTROSPECTION": "OIDC 传统内省", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "我们最近出于性能原因重构了内省端点。如果出现意外错误,可以使用此功能回滚到传统实现。", "OIDCTOKENEXCHANGE": "OIDC 令牌交换", "OIDCTOKENEXCHANGE_DESCRIPTION": "启用 OIDC 令牌端点的实验性 urn:ietf:params:oauth:grant-type:token-exchange 授权类型。令牌交换可用于请求具有较少范围的令牌或模拟其他用户。请参阅安全策略以允许在实例上模拟。", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC 触发内省投影", diff --git a/docs/docs/apis/openidoauth/scopes.md b/docs/docs/apis/openidoauth/scopes.md index 86f9769cab..d1fe9c7c5b 100644 --- a/docs/docs/apis/openidoauth/scopes.md +++ b/docs/docs/apis/openidoauth/scopes.md @@ -8,7 +8,7 @@ ZITADEL supports the usage of scopes as way of requesting information from the I ## Standard Scopes | Scopes | Description | -|:---------------|--------------------------------------------------------------------------------| +| :------------- | ------------------------------------------------------------------------------ | | openid | When using openid connect this is a mandatory scope | | profile | Optional scope to request the profile of the subject | | email | Optional scope to request the email of the subject | @@ -30,11 +30,9 @@ In addition to the standard compliant scopes we utilize the following scopes. | `urn:zitadel:iam:org:projects:roles` | `urn:zitadel:iam:org:projects:roles` | By using this scope a client can request the claim `urn:zitadel:iam:org:project:{projectid}:roles` to be asserted for each requested project. All projects of the token audience, requested by the `urn:zitadel:iam:org:project:id:{projectid}:aud` scopes will be used. | | `urn:zitadel:iam:org:id:{id}` | `urn:zitadel:iam:org:id:178204173316174381` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization. If the organization does not exist a failure is displayed. It will assert the `urn:zitadel:iam:user:resourceowner` claims. | | `urn:zitadel:iam:org:domain:primary:{domainname}` | `urn:zitadel:iam:org:domain:primary:acme.ch` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization and the username is suffixed by the provided domain. If the organization does not exist a failure is displayed | -| `urn:zitadel:iam:org:roles:id:{orgID}` | `urn:zitadel:iam:org:roles:id:178204173316174381` | This scope can be used one or more times to limit the granted organization IDs in the returned roles. Unknown organization IDs are ignored. When this scope is not used, all granted organizations are returned inside the roles.[^1] | +| `urn:zitadel:iam:org:roles:id:{orgID}` | `urn:zitadel:iam:org:roles:id:178204173316174381` | This scope can be used one or more times to limit the granted organization IDs in the returned roles. Unknown organization IDs are ignored. When this scope is not used, all granted organizations are returned inside the roles. | | `urn:zitadel:iam:org:project:id:{projectid}:aud` | `urn:zitadel:iam:org:project:id:69234237810729019:aud` | By adding this scope, the requested projectid will be added to the audience of the access token | | `urn:zitadel:iam:org:project:id:zitadel:aud` | `urn:zitadel:iam:org:project:id:zitadel:aud` | By adding this scope, the ZITADEL project ID will be added to the audience of the access token | | `urn:zitadel:iam:user:metadata` | `urn:zitadel:iam:user:metadata` | By adding this scope, the metadata of the user will be included in the token. The values are base64 encoded. | -| `urn:zitadel:iam:user:resourceowner` | `urn:zitadel:iam:user:resourceowner` | By adding this scope: id, name and primary_domain of the resource owner (the users organization) will be included in the token. | +| `urn:zitadel:iam:user:resourceowner` | `urn:zitadel:iam:user:resourceowner` | By adding this scope: id, name and primary_domain of the resource owner (the users organization) will be included in the token. | | `urn:zitadel:iam:org:idp:id:{idp_id}` | `urn:zitadel:iam:org:idp:id:76625965177954913` | By adding this scope the user will directly be redirected to the identity provider to authenticate. Make sure you also send the primary domain scope if a custom login policy is configured. Otherwise the system will not be able to identify the identity provider. | - -[^1]: `urn:zitadel:iam:org:roles:id:{orgID}` is not supported when the `oidcLegacyIntrospection` [feature flag](/docs/apis/resources/feature_service_v2/feature-service-set-instance-features) is enabled. diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index e146ac2db6..56d3009457 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -20,7 +20,6 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) (*command return &command.SystemFeatures{ LoginDefaultOrg: req.LoginDefaultOrg, TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - LegacyIntrospection: req.OidcLegacyIntrospection, UserSchema: req.UserSchema, TokenExchange: req.OidcTokenExchange, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), @@ -37,7 +36,6 @@ func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesRe 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), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), @@ -57,7 +55,6 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) (*com return &command.InstanceFeatures{ LoginDefaultOrg: req.LoginDefaultOrg, TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - LegacyIntrospection: req.OidcLegacyIntrospection, UserSchema: req.UserSchema, TokenExchange: req.OidcTokenExchange, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), @@ -77,7 +74,6 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat 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), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index f481e4f65a..b77ed438f5 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -21,7 +21,6 @@ func Test_systemFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetSystemFeaturesRequest{ LoginDefaultOrg: gu.Ptr(true), OidcTriggerIntrospectionProjections: gu.Ptr(false), - OidcLegacyIntrospection: nil, UserSchema: gu.Ptr(true), OidcTokenExchange: gu.Ptr(true), ImprovedPerformance: nil, @@ -34,7 +33,6 @@ func Test_systemFeaturesToCommand(t *testing.T) { want := &command.SystemFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: nil, UserSchema: gu.Ptr(true), TokenExchange: gu.Ptr(true), ImprovedPerformance: nil, @@ -64,10 +62,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - LegacyIntrospection: query.FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelSystem, Value: true, @@ -114,10 +108,6 @@ func Test_systemFeaturesToPb(t *testing.T) { 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, @@ -160,7 +150,6 @@ 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), ImprovedPerformance: nil, @@ -177,7 +166,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { want := &command.InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: nil, UserSchema: gu.Ptr(true), TokenExchange: gu.Ptr(true), ImprovedPerformance: nil, @@ -211,10 +199,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - LegacyIntrospection: query.FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -269,10 +253,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { 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, diff --git a/internal/api/grpc/feature/v2/integration_test/feature_test.go b/internal/api/grpc/feature/v2/integration_test/feature_test.go index f27b57ff8c..fe09242429 100644 --- a/internal/api/grpc/feature/v2/integration_test/feature_test.go +++ b/internal/api/grpc/feature/v2/integration_test/feature_test.go @@ -209,7 +209,6 @@ func TestServer_GetSystemFeatures(t *testing.T) { 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) }) } @@ -321,7 +320,7 @@ func TestServer_ResetInstanceFeatures(t *testing.T) { func TestServer_GetInstanceFeatures(t *testing.T) { _, err := Client.SetSystemFeatures(SystemCTX, &feature.SetSystemFeaturesRequest{ - OidcLegacyIntrospection: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }) require.NoError(t, err) t.Cleanup(func() { @@ -358,17 +357,13 @@ func TestServer_GetInstanceFeatures(t *testing.T) { }, want: &feature.GetInstanceFeaturesResponse{ LoginDefaultOrg: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, + Enabled: true, + Source: feature.Source_SOURCE_SYSTEM, }, 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, @@ -427,10 +422,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { 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, @@ -456,7 +447,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { 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) }) } diff --git a/internal/api/grpc/feature/v2beta/converter.go b/internal/api/grpc/feature/v2beta/converter.go index 9739e1c4c8..406146fdbe 100644 --- a/internal/api/grpc/feature/v2beta/converter.go +++ b/internal/api/grpc/feature/v2beta/converter.go @@ -12,7 +12,6 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command. return &command.SystemFeatures{ LoginDefaultOrg: req.LoginDefaultOrg, TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - LegacyIntrospection: req.OidcLegacyIntrospection, UserSchema: req.UserSchema, TokenExchange: req.OidcTokenExchange, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), @@ -25,7 +24,6 @@ func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesRe 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), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), @@ -37,7 +35,6 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *comm return &command.InstanceFeatures{ LoginDefaultOrg: req.LoginDefaultOrg, TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - LegacyIntrospection: req.OidcLegacyIntrospection, UserSchema: req.UserSchema, TokenExchange: req.OidcTokenExchange, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), @@ -52,7 +49,6 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat 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), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), diff --git a/internal/api/grpc/feature/v2beta/converter_test.go b/internal/api/grpc/feature/v2beta/converter_test.go index 72d91b10d4..2395574733 100644 --- a/internal/api/grpc/feature/v2beta/converter_test.go +++ b/internal/api/grpc/feature/v2beta/converter_test.go @@ -20,7 +20,6 @@ func Test_systemFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetSystemFeaturesRequest{ LoginDefaultOrg: gu.Ptr(true), OidcTriggerIntrospectionProjections: gu.Ptr(false), - OidcLegacyIntrospection: nil, UserSchema: gu.Ptr(true), OidcTokenExchange: gu.Ptr(true), ImprovedPerformance: nil, @@ -29,7 +28,6 @@ func Test_systemFeaturesToCommand(t *testing.T) { want := &command.SystemFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: nil, UserSchema: gu.Ptr(true), TokenExchange: gu.Ptr(true), ImprovedPerformance: nil, @@ -54,10 +52,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - LegacyIntrospection: query.FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelSystem, Value: true, @@ -89,10 +83,6 @@ func Test_systemFeaturesToPb(t *testing.T) { 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, @@ -118,7 +108,6 @@ 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), ImprovedPerformance: nil, @@ -128,7 +117,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { want := &command.InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: nil, UserSchema: gu.Ptr(true), TokenExchange: gu.Ptr(true), ImprovedPerformance: nil, @@ -154,10 +142,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - LegacyIntrospection: query.FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -193,10 +177,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { 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, diff --git a/internal/api/grpc/feature/v2beta/integration_test/feature_test.go b/internal/api/grpc/feature/v2beta/integration_test/feature_test.go index cbd9f5f939..549bc4ef0a 100644 --- a/internal/api/grpc/feature/v2beta/integration_test/feature_test.go +++ b/internal/api/grpc/feature/v2beta/integration_test/feature_test.go @@ -194,10 +194,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, }, - OidcLegacyIntrospection: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -256,10 +252,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, }, - OidcLegacyIntrospection: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -285,7 +277,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { 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) }) } diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index da0e084877..b29e157fc2 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -24,7 +24,6 @@ import ( "github.com/zitadel/zitadel/internal/domain/federatedlogout" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/user/model" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -216,11 +215,11 @@ func (o *OPStorage) SaveAuthCode(ctx context.Context, id, code string) (err erro return o.repo.SaveAuthCode(ctx, id, code, userAgentID) } -func (o *OPStorage) DeleteAuthRequest(ctx context.Context, id string) (err error) { +func (o *OPStorage) DeleteAuthRequest(context.Context, string) error { panic(o.panicErr("DeleteAuthRequest")) } -func (o *OPStorage) CreateAccessToken(ctx context.Context, req op.TokenRequest) (string, time.Time, error) { +func (o *OPStorage) CreateAccessToken(context.Context, op.TokenRequest) (string, time.Time, error) { panic(o.panicErr("CreateAccessToken")) } @@ -492,34 +491,6 @@ func (o *OPStorage) GetRefreshTokenInfo(ctx context.Context, clientID string, to return refreshToken.UserID, refreshToken.ID, nil } -func (o *OPStorage) assertProjectRoleScopes(ctx context.Context, clientID string, scopes []string) ([]string, error) { - for _, scope := range scopes { - if strings.HasPrefix(scope, ScopeProjectRolePrefix) { - return scopes, nil - } - } - - project, err := o.query.ProjectByOIDCClientID(ctx, clientID) - if err != nil { - return nil, zerrors.ThrowPreconditionFailed(nil, "OIDC-w4wIn", "Errors.Internal") - } - if !project.ProjectRoleAssertion { - return scopes, nil - } - projectIDQuery, err := query.NewProjectRoleProjectIDSearchQuery(project.ID) - if err != nil { - return nil, zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") - } - roles, err := o.query.SearchProjectRoles(ctx, true, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) - if err != nil { - return nil, err - } - for _, role := range roles.ProjectRoles { - scopes = append(scopes, ScopeProjectRolePrefix+role.Key) - } - return scopes, nil -} - func (o *OPStorage) assertProjectRoleScopesByProject(ctx context.Context, project *query.Project, scopes []string) ([]string, error) { for _, scope := range scopes { if strings.HasPrefix(scope, ScopeProjectRolePrefix) { @@ -543,22 +514,6 @@ func (o *OPStorage) assertProjectRoleScopesByProject(ctx context.Context, projec return scopes, nil } -func (o *OPStorage) assertClientScopesForPAT(ctx context.Context, token *model.TokenView, clientID, projectID string) error { - token.Audience = append(token.Audience, clientID) - projectIDQuery, err := query.NewProjectRoleProjectIDSearchQuery(projectID) - if err != nil { - return zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") - } - roles, err := o.query.SearchProjectRoles(ctx, true, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) - if err != nil { - return err - } - for _, role := range roles.ProjectRoles { - token.Scopes = append(token.Scopes, ScopeProjectRolePrefix+role.Key) - } - return nil -} - func setContextUserSystem(ctx context.Context) context.Context { data := authz.CtxData{ UserID: "SYSTEM", diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index 08ed8c31b9..6a2639f213 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -2,25 +2,17 @@ package oidc import ( "context" - "encoding/base64" "encoding/json" - "fmt" "slices" "strings" "time" - "github.com/dop251/goja" "github.com/go-jose/go-jose/v4" - "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" "github.com/zitadel/zitadel/internal/actions" - "github.com/zitadel/zitadel/internal/actions/object" "github.com/zitadel/zitadel/internal/api/authz" - api_http "github.com/zitadel/zitadel/internal/api/http" - "github.com/zitadel/zitadel/internal/command" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -44,6 +36,9 @@ const ( oidcCtx = "oidc" ) +// GetClientByClientID implements the op.Storage interface to retrieve an OIDC client by its ID. +// +// TODO: Still used for Auth request creation for v1 login. func (o *OPStorage) GetClientByClientID(ctx context.Context, id string) (_ op.Client, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { @@ -57,819 +52,44 @@ func (o *OPStorage) GetClientByClientID(ctx context.Context, id string) (_ op.Cl return ClientFromBusiness(client, o.defaultLoginURL, o.defaultLoginURLV2), nil } -func (o *OPStorage) GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (_ *jose.JSONWebKey, err error) { - return o.GetKeyByIDAndIssuer(ctx, keyID, userID) +func (o *OPStorage) GetKeyByIDAndClientID(context.Context, string, string) (*jose.JSONWebKey, error) { + panic(o.panicErr("GetKeyByIDAndClientID")) } -func (o *OPStorage) GetKeyByIDAndIssuer(ctx context.Context, keyID, issuer string) (_ *jose.JSONWebKey, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - publicKeyData, err := o.query.GetAuthNKeyPublicKeyByIDAndIdentifier(ctx, keyID, issuer) - if err != nil { - return nil, err - } - publicKey, err := crypto.BytesToPublicKey(publicKeyData) - if err != nil { - return nil, err - } - return &jose.JSONWebKey{ - KeyID: keyID, - Use: "sig", - Key: publicKey, - }, nil +func (o *OPStorage) ValidateJWTProfileScopes(context.Context, string, []string) ([]string, error) { + panic(o.panicErr("ValidateJWTProfileScopes")) } -func (o *OPStorage) ValidateJWTProfileScopes(ctx context.Context, subject string, scopes []string) (_ []string, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - user, err := o.query.GetUserByID(ctx, true, subject) - if err != nil { - return nil, err - } - return o.checkOrgScopes(ctx, user, scopes) +func (o *OPStorage) AuthorizeClientIDSecret(context.Context, string, string) error { + panic(o.panicErr("AuthorizeClientIDSecret")) } -func (o *OPStorage) AuthorizeClientIDSecret(ctx context.Context, id string, secret string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - ctx = authz.SetCtxData(ctx, authz.CtxData{ - UserID: oidcCtx, - OrgID: oidcCtx, - }) - app, err := o.query.AppByClientID(ctx, id) - if err != nil { - return err - } - if app.OIDCConfig != nil { - return o.command.VerifyOIDCClientSecret(ctx, app.ProjectID, app.ID, secret) - } - return o.command.VerifyAPIClientSecret(ctx, app.ProjectID, app.ID, secret) +func (o *OPStorage) SetUserinfoFromToken(context.Context, *oidc.UserInfo, string, string, string) error { + panic(o.panicErr("SetUserinfoFromToken")) } -func (o *OPStorage) SetUserinfoFromToken(ctx context.Context, userInfo *oidc.UserInfo, tokenID, subject, origin string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - - if strings.HasPrefix(tokenID, command.IDPrefixV2) { - token, err := o.query.ActiveAccessTokenByToken(ctx, tokenID) - if err != nil { - return err - } - if err = o.isOriginAllowed(ctx, token.ClientID, origin); err != nil { - return err - } - return o.setUserinfo(ctx, userInfo, token.UserID, token.ClientID, token.Scope, nil) - } - - token, err := o.repo.TokenByIDs(ctx, subject, tokenID) - if err != nil { - return zerrors.ThrowPermissionDenied(nil, "OIDC-Dsfb2", "token is not valid or has expired") - } - if token.ApplicationID != "" { - if err = o.isOriginAllowed(ctx, token.ApplicationID, origin); err != nil { - return err - } - } - return o.setUserinfo(ctx, userInfo, token.UserID, token.ApplicationID, token.Scopes, nil) +func (o *OPStorage) SetUserinfoFromScopes(context.Context, *oidc.UserInfo, string, string, []string) error { + panic(o.panicErr("SetUserinfoFromScopes")) } -func (o *OPStorage) SetUserinfoFromScopes(ctx context.Context, userInfo *oidc.UserInfo, userID, applicationID string, scopes []string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - if applicationID != "" { - app, err := o.query.AppByOIDCClientID(ctx, applicationID) - if err != nil { - return err - } - if app.OIDCConfig.AssertIDTokenRole { - scopes, err = o.assertProjectRoleScopes(ctx, applicationID, scopes) - if err != nil { - return zerrors.ThrowPreconditionFailed(err, "OIDC-Dfe2s", "Errors.Internal") - } - } - } - return o.setUserinfo(ctx, userInfo, userID, applicationID, scopes, nil) +func (o *OPStorage) SetUserinfoFromRequest(context.Context, *oidc.UserInfo, op.IDTokenRequest, []string) error { + panic(o.panicErr("SetUserinfoFromRequest")) } -// SetUserinfoFromRequest extends the SetUserinfoFromScopes during the id_token generation. -// This is required for V2 tokens to be able to set the sessionID (`sid`) claim. -func (o *OPStorage) SetUserinfoFromRequest(ctx context.Context, userinfo *oidc.UserInfo, request op.IDTokenRequest, _ []string) error { - switch t := request.(type) { - case *AuthRequestV2: - userinfo.AppendClaims("sid", t.SessionID) - case *RefreshTokenRequestV2: - userinfo.AppendClaims("sid", t.SessionID) - } - return nil +func (o *OPStorage) SetIntrospectionFromToken(context.Context, *oidc.IntrospectionResponse, string, string, string) error { + panic(o.panicErr("SetIntrospectionFromToken")) } -func (o *OPStorage) SetIntrospectionFromToken(ctx context.Context, introspection *oidc.IntrospectionResponse, tokenID, subject, clientID string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - - if strings.HasPrefix(tokenID, command.IDPrefixV2) { - token, err := o.query.ActiveAccessTokenByToken(ctx, tokenID) - if err != nil { - return err - } - projectID, err := o.query.ProjectIDFromClientID(ctx, clientID) - if err != nil { - return zerrors.ThrowPermissionDenied(nil, "OIDC-Adfg5", "client not found") - } - return o.introspect(ctx, introspection, - tokenID, token.UserID, token.ClientID, clientID, projectID, - token.Audience, token.Scope, - token.AccessTokenCreation, token.AccessTokenExpiration) - } - - token, err := o.repo.TokenByIDs(ctx, subject, tokenID) - if err != nil { - return zerrors.ThrowPermissionDenied(nil, "OIDC-Dsfb2", "token is not valid or has expired") - } - projectID, err := o.query.ProjectIDFromClientID(ctx, clientID) - if err != nil { - return zerrors.ThrowPermissionDenied(nil, "OIDC-Adfg5", "client not found") - } - if token.IsPAT { - err = o.assertClientScopesForPAT(ctx, token, clientID, projectID) - if err != nil { - return zerrors.ThrowPreconditionFailed(err, "OIDC-AGefw", "Errors.Internal") - } - } - return o.introspect(ctx, introspection, - token.ID, token.UserID, token.ApplicationID, clientID, projectID, - token.Audience, token.Scopes, - token.CreationDate, token.Expiration) +func (o *OPStorage) ClientCredentialsTokenRequest(context.Context, string, []string) (op.TokenRequest, error) { + panic(o.panicErr("ClientCredentialsTokenRequest")) } -func (o *OPStorage) ClientCredentialsTokenRequest(ctx context.Context, clientID string, scope []string) (_ op.TokenRequest, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - user, err := o.query.GetUserByLoginName(ctx, false, clientID) - if err != nil { - return nil, err - } - scope, err = o.checkOrgScopes(ctx, user, scope) - if err != nil { - return nil, err - } - audience := domain.AddAudScopeToAudience(ctx, nil, scope) - return &clientCredentialsRequest{ - sub: user.ID, - scopes: scope, - audience: audience, - }, nil -} - -// ClientCredentials method is kept to keep the storage interface implemented. -// However, it should never be called as the VerifyClient method on the Server is overridden. func (o *OPStorage) ClientCredentials(context.Context, string, string) (op.Client, error) { - return nil, zerrors.ThrowInternal(nil, "OIDC-Su8So", "Errors.Internal") + panic(o.panicErr("ClientCredentials")) } -// isOriginAllowed checks whether a call by the client to the endpoint is allowed from the provided origin -// if no origin is provided, no error will be returned -func (o *OPStorage) isOriginAllowed(ctx context.Context, clientID, origin string) error { - if origin == "" { - return nil - } - app, err := o.query.AppByOIDCClientID(ctx, clientID) - if err != nil { - return err - } - if api_http.IsOriginAllowed(app.OIDCConfig.AllowedOrigins, origin) { - return nil - } - return zerrors.ThrowPermissionDenied(nil, "OIDC-da1f3", "origin is not allowed") -} - -func (o *OPStorage) introspect( - ctx context.Context, - introspection *oidc.IntrospectionResponse, - tokenID, subject, tokenClientID, introspectionClientID, introspectionProjectID string, - audience, scope []string, - tokenCreation, tokenExpiration time.Time, -) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - for _, aud := range audience { - if aud == introspectionClientID || aud == introspectionProjectID { - userInfo := new(oidc.UserInfo) - err = o.setUserinfo(ctx, userInfo, subject, introspectionClientID, scope, []string{introspectionProjectID}) - if err != nil { - return err - } - introspection.SetUserInfo(userInfo) - introspection.Scope = scope - introspection.ClientID = tokenClientID - introspection.TokenType = oidc.BearerToken - introspection.Expiration = oidc.FromTime(tokenExpiration) - introspection.IssuedAt = oidc.FromTime(tokenCreation) - introspection.NotBefore = oidc.FromTime(tokenCreation) - introspection.Audience = audience - introspection.Issuer = op.IssuerFromContext(ctx) - introspection.JWTID = tokenID - return nil - } - } - return zerrors.ThrowPermissionDenied(nil, "OIDC-sdg3G", "token is not valid for this client") -} - -func (o *OPStorage) checkOrgScopes(ctx context.Context, user *query.User, scopes []string) ([]string, error) { - for i := len(scopes) - 1; i >= 0; i-- { - scope := scopes[i] - if strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) { - var orgID string - org, err := o.query.OrgByPrimaryDomain(ctx, strings.TrimPrefix(scope, domain.OrgDomainPrimaryScope)) - if err == nil { - orgID = org.ID - } - if orgID != user.ResourceOwner { - scopes[i] = scopes[len(scopes)-1] - scopes[len(scopes)-1] = "" - scopes = scopes[:len(scopes)-1] - } - } - if strings.HasPrefix(scope, domain.OrgIDScope) { - if strings.TrimPrefix(scope, domain.OrgIDScope) != user.ResourceOwner { - scopes[i] = scopes[len(scopes)-1] - scopes[len(scopes)-1] = "" - scopes = scopes[:len(scopes)-1] - } - } - } - return scopes, nil -} - -func (o *OPStorage) setUserinfo(ctx context.Context, userInfo *oidc.UserInfo, userID, applicationID string, scopes []string, roleAudience []string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - user, err := o.query.GetUserByID(ctx, true, userID) - if err != nil { - return err - } - if user.State != domain.UserStateActive { - return zerrors.ThrowUnauthenticated(nil, "OIDC-S3tha", "Errors.Users.NotActive") - } - var allRoles bool - roles := make([]string, 0) - for _, scope := range scopes { - switch scope { - case oidc.ScopeOpenID: - userInfo.Subject = user.ID - case oidc.ScopeEmail: - setUserInfoEmail(userInfo, user) - case oidc.ScopeProfile: - o.setUserInfoProfile(ctx, userInfo, user) - case oidc.ScopePhone: - setUserInfoPhone(userInfo, user) - case oidc.ScopeAddress: - //TODO: handle address for human users as soon as implemented - case ScopeUserMetaData: - if err := o.setUserInfoMetadata(ctx, userInfo, userID); err != nil { - return err - } - case ScopeResourceOwner: - if err := o.setUserInfoResourceOwner(ctx, userInfo, userID); err != nil { - return err - } - case ScopeProjectsRoles: - allRoles = true - default: - if strings.HasPrefix(scope, ScopeProjectRolePrefix) { - roles = append(roles, strings.TrimPrefix(scope, ScopeProjectRolePrefix)) - } - if strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) { - userInfo.AppendClaims(domain.OrgDomainPrimaryClaim, strings.TrimPrefix(scope, domain.OrgDomainPrimaryScope)) - } - if strings.HasPrefix(scope, domain.OrgIDScope) { - userInfo.AppendClaims(domain.OrgIDClaim, strings.TrimPrefix(scope, domain.OrgIDScope)) - if err := o.setUserInfoResourceOwner(ctx, userInfo, userID); err != nil { - return err - } - } - } - } - - // if all roles are requested take the audience for those from the scopes - if allRoles && len(roleAudience) == 0 { - roleAudience = domain.AddAudScopeToAudience(ctx, roleAudience, scopes) - } - - userGrants, projectRoles, err := o.assertRoles(ctx, userID, applicationID, roles, roleAudience) - if err != nil { - return err - } - o.setUserInfoRoleClaims(userInfo, projectRoles) - - return o.userinfoFlows(ctx, user, userGrants, userInfo) -} - -func (o *OPStorage) setUserInfoProfile(ctx context.Context, userInfo *oidc.UserInfo, user *query.User) { - userInfo.PreferredUsername = user.PreferredLoginName - userInfo.UpdatedAt = oidc.FromTime(user.ChangeDate) - if user.Machine != nil { - userInfo.Name = user.Machine.Name - return - } - userInfo.Name = user.Human.DisplayName - userInfo.FamilyName = user.Human.LastName - userInfo.GivenName = user.Human.FirstName - userInfo.Nickname = user.Human.NickName - userInfo.Gender = getGender(user.Human.Gender) - userInfo.Locale = oidc.NewLocale(user.Human.PreferredLanguage) - userInfo.Picture = domain.AvatarURL(o.assetAPIPrefix(ctx), user.ResourceOwner, user.Human.AvatarKey) -} - -func setUserInfoEmail(userInfo *oidc.UserInfo, user *query.User) { - if user.Human == nil { - return - } - userInfo.UserInfoEmail = oidc.UserInfoEmail{ - Email: string(user.Human.Email), - EmailVerified: oidc.Bool(user.Human.IsEmailVerified)} -} - -func setUserInfoPhone(userInfo *oidc.UserInfo, user *query.User) { - if user.Human == nil { - return - } - userInfo.UserInfoPhone = oidc.UserInfoPhone{ - PhoneNumber: string(user.Human.Phone), - PhoneNumberVerified: user.Human.IsPhoneVerified, - } -} - -func (o *OPStorage) setUserInfoMetadata(ctx context.Context, userInfo *oidc.UserInfo, userID string) error { - userMetaData, err := o.assertUserMetaData(ctx, userID) - if err != nil { - return err - } - if len(userMetaData) > 0 { - userInfo.AppendClaims(ClaimUserMetaData, userMetaData) - } - return nil -} - -func (o *OPStorage) setUserInfoResourceOwner(ctx context.Context, userInfo *oidc.UserInfo, userID string) error { - resourceOwnerClaims, err := o.assertUserResourceOwner(ctx, userID) - if err != nil { - return err - } - for claim, value := range resourceOwnerClaims { - userInfo.AppendClaims(claim, value) - } - return nil -} - -func (o *OPStorage) setUserInfoRoleClaims(userInfo *oidc.UserInfo, roles *projectsRoles) { - if roles != nil && len(roles.projects) > 0 { - if roles, ok := roles.projects[roles.requestProjectID]; ok { - userInfo.AppendClaims(ClaimProjectRoles, roles) - } - for projectID, roles := range roles.projects { - userInfo.AppendClaims(fmt.Sprintf(ClaimProjectRolesFormat, projectID), roles) - } - } -} - -func (o *OPStorage) userinfoFlows(ctx context.Context, user *query.User, userGrants *query.UserGrants, userInfo *oidc.UserInfo) error { - queriedActions, err := o.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeCustomiseToken, domain.TriggerTypePreUserinfoCreation, user.ResourceOwner) - if err != nil { - return err - } - - ctxFields := actions.SetContextFields( - actions.SetFields("v1", - actions.SetFields("claims", userinfoClaims(userInfo)), - actions.SetFields("getUser", func(c *actions.FieldConfig) interface{} { - return func(call goja.FunctionCall) goja.Value { - return object.UserFromQuery(c, user) - } - }), - actions.SetFields("user", - actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { - return func(goja.FunctionCall) goja.Value { - resourceOwnerQuery, err := query.NewUserMetadataResourceOwnerSearchQuery(user.ResourceOwner) - if err != nil { - logging.WithError(err).Debug("unable to create search query") - panic(err) - } - metadata, err := o.query.SearchUserMetadata( - ctx, - true, - userInfo.Subject, - &query.UserMetadataSearchQueries{Queries: []query.SearchQuery{resourceOwnerQuery}}, - false, - ) - if err != nil { - logging.WithError(err).Info("unable to get md in action") - panic(err) - } - return object.UserMetadataListFromQuery(c, metadata) - } - }), - actions.SetFields("grants", - func(c *actions.FieldConfig) interface{} { - return object.UserGrantsFromQuery(ctx, o.query, c, userGrants) - }, - ), - ), - actions.SetFields("org", - actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { - return func(goja.FunctionCall) goja.Value { - return object.GetOrganizationMetadata(ctx, o.query, c, user.ResourceOwner) - } - }), - ), - ), - ) - - for _, action := range queriedActions { - actionCtx, cancel := context.WithTimeout(ctx, action.Timeout()) - claimLogs := []string{} - - apiFields := actions.WithAPIFields( - actions.SetFields("v1", - actions.SetFields("userinfo", - actions.SetFields("setClaim", func(key string, value interface{}) { - if strings.HasPrefix(key, ClaimPrefix) { - return - } - if userInfo.Claims[key] == nil { - userInfo.AppendClaims(key, value) - return - } - claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key)) - }), - actions.SetFields("appendLogIntoClaims", func(entry string) { - claimLogs = append(claimLogs, entry) - }), - ), - actions.SetFields("claims", - actions.SetFields("setClaim", func(key string, value interface{}) { - if strings.HasPrefix(key, ClaimPrefix) { - return - } - if userInfo.Claims[key] == nil { - userInfo.AppendClaims(key, value) - return - } - claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key)) - }), - actions.SetFields("appendLogIntoClaims", func(entry string) { - claimLogs = append(claimLogs, entry) - }), - ), - actions.SetFields("user", - actions.SetFields("setMetadata", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) != 2 { - panic("exactly 2 (key, value) arguments expected") - } - key := call.Arguments[0].Export().(string) - val := call.Arguments[1].Export() - - value, err := json.Marshal(val) - if err != nil { - logging.WithError(err).Debug("unable to marshal") - panic(err) - } - - metadata := &domain.Metadata{ - Key: key, - Value: value, - } - if _, err = o.command.SetUserMetadata(ctx, metadata, userInfo.Subject, user.ResourceOwner); err != nil { - logging.WithError(err).Info("unable to set md in action") - panic(err) - } - return nil - }), - ), - ), - ) - - err = actions.Run( - actionCtx, - ctxFields, - apiFields, - action.Script, - action.Name, - append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))..., - ) - cancel() - if err != nil { - return err - } - if len(claimLogs) > 0 { - userInfo.AppendClaims(fmt.Sprintf(ClaimActionLogFormat, action.Name), claimLogs) - } - } - - return nil -} - -func (o *OPStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - - roles := make([]string, 0) - var allRoles bool - for _, scope := range scopes { - switch scope { - case ScopeUserMetaData: - userMetaData, err := o.assertUserMetaData(ctx, userID) - if err != nil { - return nil, err - } - if len(userMetaData) > 0 { - claims = appendClaim(claims, ClaimUserMetaData, userMetaData) - } - case ScopeResourceOwner: - resourceOwnerClaims, err := o.assertUserResourceOwner(ctx, userID) - if err != nil { - return nil, err - } - for claim, value := range resourceOwnerClaims { - claims = appendClaim(claims, claim, value) - } - case ScopeProjectsRoles: - allRoles = true - } - if strings.HasPrefix(scope, ScopeProjectRolePrefix) { - roles = append(roles, strings.TrimPrefix(scope, ScopeProjectRolePrefix)) - } - if strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) { - claims = appendClaim(claims, domain.OrgDomainPrimaryClaim, strings.TrimPrefix(scope, domain.OrgDomainPrimaryScope)) - } - if strings.HasPrefix(scope, domain.OrgIDScope) { - claims = appendClaim(claims, domain.OrgIDClaim, strings.TrimPrefix(scope, domain.OrgIDScope)) - resourceOwnerClaims, err := o.assertUserResourceOwner(ctx, userID) - if err != nil { - return nil, err - } - for claim, value := range resourceOwnerClaims { - claims = appendClaim(claims, claim, value) - } - } - } - - // If requested, use the audience as context for the roles, - // otherwise the project itself will be used - var roleAudience []string - if allRoles { - roleAudience = domain.AddAudScopeToAudience(ctx, roleAudience, scopes) - } - - userGrants, projectRoles, err := o.assertRoles(ctx, userID, clientID, roles, roleAudience) - if err != nil { - return nil, err - } - - if projectRoles != nil && len(projectRoles.projects) > 0 { - if roles, ok := projectRoles.projects[projectRoles.requestProjectID]; ok { - claims = appendClaim(claims, ClaimProjectRoles, roles) - } - for projectID, roles := range projectRoles.projects { - claims = appendClaim(claims, fmt.Sprintf(ClaimProjectRolesFormat, projectID), roles) - } - } - - return o.privateClaimsFlows(ctx, userID, userGrants, claims) -} - -func (o *OPStorage) privateClaimsFlows(ctx context.Context, userID string, userGrants *query.UserGrants, claims map[string]interface{}) (map[string]interface{}, error) { - user, err := o.query.GetUserByID(ctx, true, userID) - if err != nil { - return nil, err - } - queriedActions, err := o.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeCustomiseToken, domain.TriggerTypePreAccessTokenCreation, user.ResourceOwner) - if err != nil { - return nil, err - } - - ctxFields := actions.SetContextFields( - actions.SetFields("v1", - actions.SetFields("claims", func(c *actions.FieldConfig) interface{} { - return c.Runtime.ToValue(claims) - }), - actions.SetFields("getUser", func(c *actions.FieldConfig) interface{} { - return func(call goja.FunctionCall) goja.Value { - return object.UserFromQuery(c, user) - } - }), - actions.SetFields("user", - actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { - return func(goja.FunctionCall) goja.Value { - resourceOwnerQuery, err := query.NewUserMetadataResourceOwnerSearchQuery(user.ResourceOwner) - if err != nil { - logging.WithError(err).Debug("unable to create search query") - panic(err) - } - metadata, err := o.query.SearchUserMetadata( - ctx, - true, - userID, - &query.UserMetadataSearchQueries{Queries: []query.SearchQuery{resourceOwnerQuery}}, - false, - ) - if err != nil { - logging.WithError(err).Info("unable to get md in action") - panic(err) - } - return object.UserMetadataListFromQuery(c, metadata) - } - }), - actions.SetFields("grants", func(c *actions.FieldConfig) interface{} { - return object.UserGrantsFromQuery(ctx, o.query, c, userGrants) - }), - ), - actions.SetFields("org", - actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { - return func(goja.FunctionCall) goja.Value { - return object.GetOrganizationMetadata(ctx, o.query, c, user.ResourceOwner) - } - }), - ), - ), - ) - - for _, action := range queriedActions { - claimLogs := []string{} - actionCtx, cancel := context.WithTimeout(ctx, action.Timeout()) - - apiFields := actions.WithAPIFields( - actions.SetFields("v1", - actions.SetFields("claims", - actions.SetFields("setClaim", func(key string, value interface{}) { - if strings.HasPrefix(key, ClaimPrefix) { - return - } - if _, ok := claims[key]; !ok { - claims = appendClaim(claims, key, value) - return - } - claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key)) - }), - actions.SetFields("appendLogIntoClaims", func(entry string) { - claimLogs = append(claimLogs, entry) - }), - ), - actions.SetFields("user", - actions.SetFields("setMetadata", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) != 2 { - panic("exactly 2 (key, value) arguments expected") - } - key := call.Arguments[0].Export().(string) - val := call.Arguments[1].Export() - - value, err := json.Marshal(val) - if err != nil { - logging.WithError(err).Debug("unable to marshal") - panic(err) - } - - metadata := &domain.Metadata{ - Key: key, - Value: value, - } - if _, err = o.command.SetUserMetadata(ctx, metadata, userID, user.ResourceOwner); err != nil { - logging.WithError(err).Info("unable to set md in action") - panic(err) - } - return nil - }), - ), - ), - ) - - err = actions.Run( - actionCtx, - ctxFields, - apiFields, - action.Script, - action.Name, - append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))..., - ) - cancel() - if err != nil { - return nil, err - } - if len(claimLogs) > 0 { - claims = appendClaim(claims, fmt.Sprintf(ClaimActionLogFormat, action.Name), claimLogs) - claimLogs = nil - } - } - - return claims, nil -} - -func (o *OPStorage) assertRoles(ctx context.Context, userID, applicationID string, requestedRoles, roleAudience []string) (*query.UserGrants, *projectsRoles, error) { - if (applicationID == "" || len(requestedRoles) == 0) && len(roleAudience) == 0 { - return nil, nil, nil - } - projectID, err := o.query.ProjectIDFromClientID(ctx, applicationID) - // applicationID might contain a username (e.g. client credentials) -> ignore the not found - if err != nil && !zerrors.IsNotFound(err) { - return nil, nil, err - } - // ensure the projectID of the requesting is part of the roleAudience - if projectID != "" { - roleAudience = append(roleAudience, projectID) - } - projectQuery, err := query.NewUserGrantProjectIDsSearchQuery(roleAudience) - if err != nil { - return nil, nil, err - } - userIDQuery, err := query.NewUserGrantUserIDSearchQuery(userID) - if err != nil { - return nil, nil, err - } - activeQuery, err := query.NewUserGrantStateQuery(domain.UserGrantStateActive) - if err != nil { - return nil, nil, err - } - grants, err := o.query.UserGrants(ctx, &query.UserGrantsQueries{ - Queries: []query.SearchQuery{ - projectQuery, - userIDQuery, - activeQuery, - }, - }, true) - if err != nil { - return nil, nil, err - } - roles := new(projectsRoles) - // if specific roles where requested, check if they are granted and append them in the roles list - if len(requestedRoles) > 0 { - for _, requestedRole := range requestedRoles { - for _, grant := range grants.UserGrants { - checkGrantedRoles(roles, *grant, requestedRole, grant.ProjectID == projectID) - } - } - return grants, roles, nil - } - // no specific roles were requested, so convert any grants into roles - for _, grant := range grants.UserGrants { - for _, role := range grant.Roles { - roles.Add(grant.ProjectID, role, grant.ResourceOwner, grant.OrgPrimaryDomain, grant.ProjectID == projectID) - } - } - return grants, roles, nil -} - -func (o *OPStorage) assertUserMetaData(ctx context.Context, userID string) (map[string]string, error) { - metaData, err := o.query.SearchUserMetadata(ctx, true, userID, &query.UserMetadataSearchQueries{}, false) - if err != nil { - return nil, err - } - - userMetaData := make(map[string]string) - for _, md := range metaData.Metadata { - userMetaData[md.Key] = base64.RawURLEncoding.EncodeToString(md.Value) - } - return userMetaData, nil -} - -func (o *OPStorage) assertUserResourceOwner(ctx context.Context, userID string) (map[string]string, error) { - user, err := o.query.GetUserByID(ctx, true, userID) - if err != nil { - return nil, err - } - resourceOwner, err := o.query.OrgByID(ctx, true, user.ResourceOwner) - if err != nil { - return nil, err - } - return map[string]string{ - ClaimResourceOwnerID: resourceOwner.ID, - ClaimResourceOwnerName: resourceOwner.Name, - ClaimResourceOwnerPrimaryDomain: resourceOwner.Domain, - }, nil +func (o *OPStorage) GetPrivateClaimsFromScopes(context.Context, string, string, []string) (map[string]interface{}, error) { + panic(o.panicErr("GetPrivateClaimsFromScopes")) } func checkGrantedRoles(roles *projectsRoles, grant query.UserGrant, requestedRole string, isRequested bool) { @@ -946,14 +166,6 @@ func getGender(gender domain.Gender) oidc.Gender { return "" } -func appendClaim(claims map[string]interface{}, claim string, value interface{}) map[string]interface{} { - if claims == nil { - claims = make(map[string]interface{}) - } - claims[claim] = value - return claims -} - func userinfoClaims(userInfo *oidc.UserInfo) func(c *actions.FieldConfig) interface{} { return func(c *actions.FieldConfig) interface{} { marshalled, err := json.Marshal(userInfo) diff --git a/internal/api/oidc/client_credentials.go b/internal/api/oidc/client_credentials.go index 9087360452..c86f5da9ae 100644 --- a/internal/api/oidc/client_credentials.go +++ b/internal/api/oidc/client_credentials.go @@ -14,27 +14,6 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -type clientCredentialsRequest struct { - sub string - audience []string - scopes []string -} - -// GetSubject returns the subject for token to be created because of the client credentials request -// the subject will be the id of the service user -func (c *clientCredentialsRequest) GetSubject() string { - return c.sub -} - -// GetAudience returns the audience for token to be created because of the client credentials request -func (c *clientCredentialsRequest) GetAudience() []string { - return c.audience -} - -func (c *clientCredentialsRequest) GetScopes() []string { - return c.scopes -} - func (s *Server) clientCredentialsAuth(ctx context.Context, clientID, clientSecret string) (op.Client, error) { user, err := s.query.GetUserByLoginName(ctx, false, clientID) if zerrors.IsNotFound(err) { diff --git a/internal/api/oidc/device_auth.go b/internal/api/oidc/device_auth.go index a10cba499d..8912ad1736 100644 --- a/internal/api/oidc/device_auth.go +++ b/internal/api/oidc/device_auth.go @@ -88,6 +88,6 @@ func (o *OPStorage) StoreDeviceAuthorization(ctx context.Context, clientID, devi return err } -func (o *OPStorage) GetDeviceAuthorizatonState(ctx context.Context, _, deviceCode string) (state *op.DeviceAuthorizationState, err error) { - return nil, nil +func (o *OPStorage) GetDeviceAuthorizatonState(context.Context, string, string) (*op.DeviceAuthorizationState, error) { + panic(o.panicErr("GetDeviceAuthorizatonState")) } diff --git a/internal/api/oidc/integration_test/token_exchange_test.go b/internal/api/oidc/integration_test/token_exchange_test.go index dcd2d61669..56a50a7c96 100644 --- a/internal/api/oidc/integration_test/token_exchange_test.go +++ b/internal/api/oidc/integration_test/token_exchange_test.go @@ -52,13 +52,6 @@ func setTokenExchangeFeature(t *testing.T, instance *integration.Instance, value time.Sleep(time.Second) } -func resetFeatures(t *testing.T, instance *integration.Instance) { - iamCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - _, err := instance.Client.FeatureV2.ResetInstanceFeatures(iamCTX, &feature.ResetInstanceFeaturesRequest{}) - require.NoError(t, err) - time.Sleep(time.Second) -} - func setImpersonationPolicy(t *testing.T, instance *integration.Instance, value bool) { iamCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) diff --git a/internal/api/oidc/integration_test/userinfo_test.go b/internal/api/oidc/integration_test/userinfo_test.go index d4aded0b48..bf201b242e 100644 --- a/internal/api/oidc/integration_test/userinfo_test.go +++ b/internal/api/oidc/integration_test/userinfo_test.go @@ -34,22 +34,11 @@ func TestServer_UserInfo(t *testing.T) { }) tests := []struct { name string - legacy bool trigger bool webKey bool }{ { - name: "legacy enabled", - legacy: true, - }, - { - name: "legacy disabled, trigger disabled", - legacy: false, - trigger: false, - }, - { - name: "legacy disabled, trigger enabled", - legacy: false, + name: "trigger enabled", trigger: true, }, @@ -59,7 +48,6 @@ func TestServer_UserInfo(t *testing.T) { // - By calling userinfo with the access token as JWT, the Token Verifier with the public key cache is tested. { name: "web keys", - legacy: false, trigger: false, webKey: true, }, @@ -68,7 +56,6 @@ func TestServer_UserInfo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := Instance.Client.FeatureV2.SetInstanceFeatures(iamOwnerCTX, &feature.SetInstanceFeaturesRequest{ - OidcLegacyIntrospection: &tt.legacy, OidcTriggerIntrospectionProjections: &tt.trigger, WebKey: &tt.webKey, }) diff --git a/internal/api/oidc/introspect.go b/internal/api/oidc/introspect.go index c028013d6a..ee022eb3e9 100644 --- a/internal/api/oidc/introspect.go +++ b/internal/api/oidc/introspect.go @@ -25,9 +25,6 @@ func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionR }() features := authz.GetFeatures(ctx) - if features.LegacyIntrospection { - return s.LegacyServer.Introspect(ctx, r) - } if features.TriggerIntrospectionProjections { query.TriggerIntrospectionProjections(ctx) } diff --git a/internal/api/oidc/jwt-profile.go b/internal/api/oidc/jwt-profile.go index fe668b5a8a..d230f58b5b 100644 --- a/internal/api/oidc/jwt-profile.go +++ b/internal/api/oidc/jwt-profile.go @@ -3,38 +3,9 @@ package oidc import ( "context" - "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" - - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/zerrors" ) -func (o *OPStorage) JWTProfileTokenType(ctx context.Context, request op.TokenRequest) (_ op.AccessTokenType, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - - mapJWTProfileScopesToAudience(ctx, request) - user, err := o.query.GetUserByID(ctx, false, request.GetSubject()) - if err != nil { - return 0, err - } - // the user should always be a machine, but let's just be sure - if user.Machine == nil { - return 0, zerrors.ThrowInvalidArgument(nil, "OIDC-jk26S", "invalid client type") - } - return accessTokenTypeToOIDC(user.Machine.AccessTokenType), nil -} - -func mapJWTProfileScopesToAudience(ctx context.Context, request op.TokenRequest) { - // the request should always be a JWTTokenRequest, but let's make sure - jwt, ok := request.(*oidc.JWTTokenRequest) - if !ok { - return - } - jwt.Audience = domain.AddAudScopeToAudience(ctx, jwt.Audience, jwt.Scopes) +func (o *OPStorage) JWTProfileTokenType(context.Context, op.TokenRequest) (op.AccessTokenType, error) { + panic(o.panicErr("JWTProfileTokenType")) } diff --git a/internal/api/oidc/userinfo.go b/internal/api/oidc/userinfo.go index 61f03b6d0f..5266500e7a 100644 --- a/internal/api/oidc/userinfo.go +++ b/internal/api/oidc/userinfo.go @@ -35,9 +35,6 @@ func (s *Server) UserInfo(ctx context.Context, r *op.Request[oidc.UserInfoReques }() features := authz.GetFeatures(ctx) - if features.LegacyIntrospection { - return s.LegacyServer.UserInfo(ctx, r) - } if features.TriggerIntrospectionProjections { query.TriggerOIDCUserInfoProjections(ctx) } diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go index cb12bff828..4d35d5a318 100644 --- a/internal/command/instance_features.go +++ b/internal/command/instance_features.go @@ -18,7 +18,6 @@ import ( type InstanceFeatures struct { LoginDefaultOrg *bool TriggerIntrospectionProjections *bool - LegacyIntrospection *bool UserSchema *bool TokenExchange *bool ImprovedPerformance []feature.ImprovedPerformanceType @@ -35,7 +34,6 @@ type InstanceFeatures struct { func (m *InstanceFeatures) isEmpty() bool { return m.LoginDefaultOrg == nil && m.TriggerIntrospectionProjections == nil && - m.LegacyIntrospection == nil && m.UserSchema == nil && m.TokenExchange == nil && // nil check to allow unset improvements diff --git a/internal/command/instance_features_model.go b/internal/command/instance_features_model.go index 977a46b6c2..399013aded 100644 --- a/internal/command/instance_features_model.go +++ b/internal/command/instance_features_model.go @@ -68,7 +68,6 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceResetEventType, feature_v2.InstanceLoginDefaultOrgEventType, feature_v2.InstanceTriggerIntrospectionProjectionsEventType, - feature_v2.InstanceLegacyIntrospectionEventType, feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, feature_v2.InstanceImprovedPerformanceEventType, @@ -98,9 +97,6 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an case feature.KeyTriggerIntrospectionProjections: v := value.(bool) features.TriggerIntrospectionProjections = &v - case feature.KeyLegacyIntrospection: - v := value.(bool) - features.LegacyIntrospection = &v case feature.KeyTokenExchange: v := value.(bool) features.TokenExchange = &v @@ -141,7 +137,6 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan cmds := make([]eventstore.Command, 0, len(feature.KeyValues())-1) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.InstanceLoginDefaultOrgEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.InstanceTriggerIntrospectionProjectionsEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LegacyIntrospection, f.LegacyIntrospection, feature_v2.InstanceLegacyIntrospectionEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.InstanceTokenExchangeEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.InstanceUserSchemaEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.InstanceImprovedPerformanceEventType) diff --git a/internal/command/instance_features_test.go b/internal/command/instance_features_test.go index 02e8896a0c..8d0c7d5964 100644 --- a/internal/command/instance_features_test.go +++ b/internal/command/instance_features_test.go @@ -113,24 +113,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ResourceOwner: "instance1", }, }, - { - name: "set LegacyIntrospection", - eventstore: expectEventstore( - expectFilter(), - expectPush( - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, true, - ), - ), - ), - args: args{ctx, &InstanceFeatures{ - LegacyIntrospection: gu.Ptr(true), - }}, - want: &domain.ObjectDetails{ - ResourceOwner: "instance1", - }, - }, { name: "set UserSchema", eventstore: expectEventstore( @@ -156,12 +138,12 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { expectPushFailed(io.ErrClosedPipe, feature_v2.NewSetEvent[bool]( ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, true, + feature_v2.InstanceConsoleUseV2UserApi, true, ), ), ), args: args{ctx, &InstanceFeatures{ - LegacyIntrospection: gu.Ptr(true), + ConsoleUseV2UserApi: gu.Ptr(true), }}, wantErr: io.ErrClosedPipe, }, @@ -178,10 +160,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceTriggerIntrospectionProjectionsEventType, false, ), - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, true, - ), feature_v2.NewSetEvent[bool]( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, true, @@ -195,7 +173,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { args: args{ctx, &InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: gu.Ptr(true), UserSchema: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(true), }}, @@ -224,10 +201,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, true, - )), feature_v2.NewSetEvent[bool]( context.Background(), aggregate, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType, false, @@ -247,7 +220,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { args: args{ctx, &InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(false), }}, want: &domain.ObjectDetails{ diff --git a/internal/command/project_application_api.go b/internal/command/project_application_api.go index 5b8bbafcdf..2832dcf873 100644 --- a/internal/command/project_application_api.go +++ b/internal/command/project_application_api.go @@ -226,37 +226,6 @@ func (c *Commands) ChangeAPIApplicationSecret(ctx context.Context, projectID, ap return result, err } -func (c *Commands) VerifyAPIClientSecret(ctx context.Context, projectID, appID, secret string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - app, err := c.getAPIAppWriteModel(ctx, projectID, appID, "") - if err != nil { - return err - } - if !app.State.Exists() { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-DFnbf", "Errors.Project.App.NotExisting") - } - if !app.IsAPI() { - return zerrors.ThrowInvalidArgument(nil, "COMMAND-Bf3fw", "Errors.Project.App.IsNotAPI") - } - if app.HashedSecret == "" { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-D3t5g", "Errors.Project.App.APIConfigInvalid") - } - - projectAgg := ProjectAggregateFromWriteModel(&app.WriteModel) - ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "passwap.Verify") - updated, err := c.secretHasher.Verify(app.HashedSecret, secret) - spanPasswordComparison.EndWithError(err) - if err != nil { - return zerrors.ThrowInvalidArgument(err, "COMMAND-SADfg", "Errors.Project.App.ClientSecretInvalid") - } - if updated != "" { - c.apiUpdateSecret(ctx, projectAgg, app.AppID, updated) - } - return nil -} - func (c *Commands) APIUpdateSecret(ctx context.Context, appID, projectID, resourceOwner, updated string) { agg := project_repo.NewAggregate(projectID, resourceOwner) c.apiUpdateSecret(ctx, &agg.Aggregate, appID, updated) diff --git a/internal/command/project_application_api_test.go b/internal/command/project_application_api_test.go index 2702c00b39..a6d4349254 100644 --- a/internal/command/project_application_api_test.go +++ b/internal/command/project_application_api_test.go @@ -2,16 +2,11 @@ package command import ( "context" - "io" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/zitadel/passwap" - "github.com/zitadel/passwap/bcrypt" "github.com/zitadel/zitadel/internal/command/preparation" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -771,99 +766,3 @@ func newAPIAppChangedEvent(ctx context.Context, appID, projectID, resourceOwner ) return event } - -func TestCommands_VerifyAPIClientSecret(t *testing.T) { - hasher := &crypto.Hasher{ - Swapper: passwap.NewSwapper(bcrypt.New(bcrypt.MinCost)), - } - hashedSecret, err := hasher.Hash("secret") - require.NoError(t, err) - agg := project.NewAggregate("projectID", "orgID") - - tests := []struct { - name string - secret string - eventstore func(*testing.T) *eventstore.Eventstore - wantErr error - }{ - { - name: "filter error", - eventstore: expectEventstore( - expectFilterError(io.ErrClosedPipe), - ), - wantErr: io.ErrClosedPipe, - }, - { - name: "app not exists", - eventstore: expectEventstore( - expectFilter(), - ), - wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-DFnbf", "Errors.Project.App.NotExisting"), - }, - { - name: "wrong app type", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - ), - ), - wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-Bf3fw", "Errors.Project.App.IsNotAPI"), - }, - { - name: "no secret set", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewAPIConfigAddedEvent(context.Background(), &agg.Aggregate, "appID", "clientID", "", domain.APIAuthMethodTypePrivateKeyJWT), - ), - ), - ), - wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-D3t5g", "Errors.Project.App.APIConfigInvalid"), - }, - { - name: "check succeeded", - secret: "secret", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewAPIConfigAddedEvent(context.Background(), &agg.Aggregate, "appID", "clientID", hashedSecret, domain.APIAuthMethodTypePrivateKeyJWT), - ), - ), - ), - }, - { - name: "check failed", - secret: "wrong!", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewAPIConfigAddedEvent(context.Background(), &agg.Aggregate, "appID", "clientID", hashedSecret, domain.APIAuthMethodTypePrivateKeyJWT), - ), - ), - ), - wantErr: zerrors.ThrowInvalidArgument(err, "COMMAND-SADfg", "Errors.Project.App.ClientSecretInvalid"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := &Commands{ - eventstore: tt.eventstore(t), - secretHasher: hasher, - } - err := c.VerifyAPIClientSecret(context.Background(), "projectID", "appID", tt.secret) - c.jobs.Wait() - require.ErrorIs(t, err, tt.wantErr) - }) - } -} diff --git a/internal/command/project_application_oidc.go b/internal/command/project_application_oidc.go index 491bd38fca..77ef7ff0c7 100644 --- a/internal/command/project_application_oidc.go +++ b/internal/command/project_application_oidc.go @@ -322,37 +322,6 @@ func (c *Commands) ChangeOIDCApplicationSecret(ctx context.Context, projectID, a return result, err } -func (c *Commands) VerifyOIDCClientSecret(ctx context.Context, projectID, appID, secret string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - app, err := c.getOIDCAppWriteModel(ctx, projectID, appID, "") - if err != nil { - return err - } - if !app.State.Exists() { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-D8hba", "Errors.Project.App.NotExisting") - } - if !app.IsOIDC() { - return zerrors.ThrowInvalidArgument(nil, "COMMAND-BHgn2", "Errors.Project.App.IsNotOIDC") - } - if app.HashedSecret == "" { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-D6hba", "Errors.Project.App.OIDCConfigInvalid") - } - - projectAgg := ProjectAggregateFromWriteModel(&app.WriteModel) - ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "passwap.Verify") - updated, err := c.secretHasher.Verify(app.HashedSecret, secret) - spanPasswordComparison.EndWithError(err) - if err != nil { - return zerrors.ThrowInvalidArgument(err, "COMMAND-Bz542", "Errors.Project.App.ClientSecretInvalid") - } - if updated != "" { - c.oidcUpdateSecret(ctx, projectAgg, appID, updated) - } - return nil -} - func (c *Commands) OIDCUpdateSecret(ctx context.Context, appID, projectID, resourceOwner, updated string) { agg := project_repo.NewAggregate(projectID, resourceOwner) c.oidcUpdateSecret(ctx, &agg.Aggregate, appID, updated) diff --git a/internal/command/project_application_oidc_test.go b/internal/command/project_application_oidc_test.go index 4b9f5bf94f..d0383b1b29 100644 --- a/internal/command/project_application_oidc_test.go +++ b/internal/command/project_application_oidc_test.go @@ -2,18 +2,13 @@ package command import ( "context" - "io" "testing" "time" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/zitadel/passwap" - "github.com/zitadel/passwap/bcrypt" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -1307,168 +1302,3 @@ func newOIDCAppChangedEvent(ctx context.Context, appID, projectID, resourceOwner ) return event } - -func TestCommands_VerifyOIDCClientSecret(t *testing.T) { - hasher := &crypto.Hasher{ - Swapper: passwap.NewSwapper(bcrypt.New(bcrypt.MinCost)), - } - hashedSecret, err := hasher.Hash("secret") - require.NoError(t, err) - agg := project.NewAggregate("projectID", "orgID") - - tests := []struct { - name string - secret string - eventstore func(*testing.T) *eventstore.Eventstore - wantErr error - }{ - { - name: "filter error", - eventstore: expectEventstore( - expectFilterError(io.ErrClosedPipe), - ), - wantErr: io.ErrClosedPipe, - }, - { - name: "app not exists", - eventstore: expectEventstore( - expectFilter(), - ), - wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-D8hba", "Errors.Project.App.NotExisting"), - }, - { - name: "wrong app type", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - ), - ), - wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-BHgn2", "Errors.Project.App.IsNotOIDC"), - }, - { - name: "no secret set", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewOIDCConfigAddedEvent(context.Background(), - &agg.Aggregate, - domain.OIDCVersionV1, - "appID", - "client1@project", - "", - []string{"https://test.ch"}, - []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, - []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - domain.OIDCApplicationTypeWeb, - domain.OIDCAuthMethodTypePost, - []string{"https://test.ch/logout"}, - true, - domain.OIDCTokenTypeBearer, - true, - true, - true, - time.Second*1, - []string{"https://sub.test.ch"}, - false, - "", - domain.LoginVersionUnspecified, - "", - ), - ), - ), - ), - wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-D6hba", "Errors.Project.App.OIDCConfigInvalid"), - }, - { - name: "check succeeded", - secret: "secret", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewOIDCConfigAddedEvent(context.Background(), - &agg.Aggregate, - domain.OIDCVersionV1, - "appID", - "client1@project", - hashedSecret, - []string{"https://test.ch"}, - []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, - []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - domain.OIDCApplicationTypeWeb, - domain.OIDCAuthMethodTypePost, - []string{"https://test.ch/logout"}, - true, - domain.OIDCTokenTypeBearer, - true, - true, - true, - time.Second*1, - []string{"https://sub.test.ch"}, - false, - "", - domain.LoginVersionUnspecified, - "", - ), - ), - ), - ), - }, - { - name: "check failed", - secret: "wrong!", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewOIDCConfigAddedEvent(context.Background(), - &agg.Aggregate, - domain.OIDCVersionV1, - "appID", - "client1@project", - hashedSecret, - []string{"https://test.ch"}, - []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, - []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - domain.OIDCApplicationTypeWeb, - domain.OIDCAuthMethodTypePost, - []string{"https://test.ch/logout"}, - true, - domain.OIDCTokenTypeBearer, - true, - true, - true, - time.Second*1, - []string{"https://sub.test.ch"}, - false, - "", - domain.LoginVersionUnspecified, - "", - ), - ), - ), - ), - wantErr: zerrors.ThrowInvalidArgument(err, "COMMAND-Bz542", "Errors.Project.App.ClientSecretInvalid"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := &Commands{ - eventstore: tt.eventstore(t), - secretHasher: hasher, - } - err := c.VerifyOIDCClientSecret(context.Background(), "projectID", "appID", tt.secret) - c.jobs.Wait() - require.ErrorIs(t, err, tt.wantErr) - }) - } -} diff --git a/internal/command/system_features.go b/internal/command/system_features.go index b317ea93bb..f20c9f3cda 100644 --- a/internal/command/system_features.go +++ b/internal/command/system_features.go @@ -12,7 +12,6 @@ import ( type SystemFeatures struct { LoginDefaultOrg *bool TriggerIntrospectionProjections *bool - LegacyIntrospection *bool TokenExchange *bool UserSchema *bool ImprovedPerformance []feature.ImprovedPerformanceType @@ -26,7 +25,6 @@ type SystemFeatures struct { func (m *SystemFeatures) isEmpty() bool { return m.LoginDefaultOrg == nil && m.TriggerIntrospectionProjections == nil && - m.LegacyIntrospection == nil && m.UserSchema == nil && m.TokenExchange == nil && // nil check to allow unset improvements diff --git a/internal/command/system_features_model.go b/internal/command/system_features_model.go index 28e56f8bd4..f1e6ba6357 100644 --- a/internal/command/system_features_model.go +++ b/internal/command/system_features_model.go @@ -61,7 +61,6 @@ func (m *SystemFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemResetEventType, feature_v2.SystemLoginDefaultOrgEventType, feature_v2.SystemTriggerIntrospectionProjectionsEventType, - feature_v2.SystemLegacyIntrospectionEventType, feature_v2.SystemUserSchemaEventType, feature_v2.SystemTokenExchangeEventType, feature_v2.SystemImprovedPerformanceEventType, @@ -88,9 +87,6 @@ func reduceSystemFeature(features *SystemFeatures, key feature.Key, value any) { case feature.KeyTriggerIntrospectionProjections: v := value.(bool) features.TriggerIntrospectionProjections = &v - case feature.KeyLegacyIntrospection: - v := value.(bool) - features.LegacyIntrospection = &v case feature.KeyUserSchema: v := value.(bool) features.UserSchema = &v @@ -121,7 +117,6 @@ func (wm *SystemFeaturesWriteModel) setCommands(ctx context.Context, f *SystemFe cmds := make([]eventstore.Command, 0, len(feature.KeyValues())-1) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.SystemLoginDefaultOrgEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.SystemTriggerIntrospectionProjectionsEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LegacyIntrospection, f.LegacyIntrospection, feature_v2.SystemLegacyIntrospectionEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.SystemUserSchemaEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.SystemTokenExchangeEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.SystemImprovedPerformanceEventType) diff --git a/internal/command/system_features_test.go b/internal/command/system_features_test.go index b1b5207b8c..ff6aef8104 100644 --- a/internal/command/system_features_test.go +++ b/internal/command/system_features_test.go @@ -81,24 +81,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { ResourceOwner: "SYSTEM", }, }, - { - name: "set LegacyIntrospection", - eventstore: expectEventstore( - expectFilter(), - expectPush( - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, true, - ), - ), - ), - args: args{context.Background(), &SystemFeatures{ - LegacyIntrospection: gu.Ptr(true), - }}, - want: &domain.ObjectDetails{ - ResourceOwner: "SYSTEM", - }, - }, { name: "set UserSchema", eventstore: expectEventstore( @@ -124,12 +106,12 @@ func TestCommands_SetSystemFeatures(t *testing.T) { expectPushFailed(io.ErrClosedPipe, feature_v2.NewSetEvent[bool]( context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, true, + feature_v2.SystemEnableBackChannelLogout, true, ), ), ), args: args{context.Background(), &SystemFeatures{ - LegacyIntrospection: gu.Ptr(true), + EnableBackChannelLogout: gu.Ptr(true), }}, wantErr: io.ErrClosedPipe, }, @@ -146,10 +128,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemTriggerIntrospectionProjectionsEventType, false, ), - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, true, - ), feature_v2.NewSetEvent[bool]( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, true, @@ -163,7 +141,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { args: args{context.Background(), &SystemFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: gu.Ptr(true), UserSchema: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(true), }}, @@ -192,10 +169,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, true, - )), ), expectPush( feature_v2.NewSetEvent[bool]( @@ -219,7 +192,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { args: args{context.Background(), &SystemFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: gu.Ptr(true), UserSchema: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(false), }}, diff --git a/internal/feature/feature.go b/internal/feature/feature.go index b5f5a901d4..2e32b6b122 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -9,22 +9,22 @@ import ( type Key int const ( - KeyUnspecified Key = iota - KeyLoginDefaultOrg - KeyTriggerIntrospectionProjections - KeyLegacyIntrospection - KeyUserSchema - KeyTokenExchange - KeyActionsDeprecated - KeyImprovedPerformance - KeyWebKey - KeyDebugOIDCParentError - KeyOIDCSingleV1SessionTermination - KeyDisableUserTokenEvent - KeyEnableBackChannelLogout - KeyLoginV2 - KeyPermissionCheckV2 - KeyConsoleUseV2UserApi + // Reserved: 3, 6 + + KeyUnspecified Key = 0 + KeyLoginDefaultOrg Key = 1 + KeyTriggerIntrospectionProjections Key = 2 + KeyUserSchema Key = 4 + KeyTokenExchange Key = 5 + KeyImprovedPerformance Key = 7 + KeyWebKey Key = 8 + KeyDebugOIDCParentError Key = 9 + KeyOIDCSingleV1SessionTermination Key = 10 + KeyDisableUserTokenEvent Key = 11 + KeyEnableBackChannelLogout Key = 12 + KeyLoginV2 Key = 13 + KeyPermissionCheckV2 Key = 14 + KeyConsoleUseV2UserApi Key = 15 ) //go:generate enumer -type Level -transform snake -trimprefix Level @@ -43,7 +43,6 @@ const ( type Features struct { LoginDefaultOrg bool `json:"login_default_org,omitempty"` TriggerIntrospectionProjections bool `json:"trigger_introspection_projections,omitempty"` - LegacyIntrospection bool `json:"legacy_introspection,omitempty"` UserSchema bool `json:"user_schema,omitempty"` TokenExchange bool `json:"token_exchange,omitempty"` ImprovedPerformance []ImprovedPerformanceType `json:"improved_performance,omitempty"` diff --git a/internal/feature/feature_test.go b/internal/feature/feature_test.go index 70e3ec9ffb..abb8968d6f 100644 --- a/internal/feature/feature_test.go +++ b/internal/feature/feature_test.go @@ -12,7 +12,6 @@ func TestKey(t *testing.T) { "unspecified", "login_default_org", "trigger_introspection_projections", - "legacy_introspection", } for _, want := range tests { t.Run(want, func(t *testing.T) { diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index a47b3eb4d9..e06199120a 100644 --- a/internal/feature/key_enumer.go +++ b/internal/feature/key_enumer.go @@ -7,17 +7,34 @@ import ( "strings" ) -const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactions_deprecatedimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" +const ( + _KeyName_0 = "unspecifiedlogin_default_orgtrigger_introspection_projections" + _KeyLowerName_0 = "unspecifiedlogin_default_orgtrigger_introspection_projections" + _KeyName_1 = "user_schematoken_exchange" + _KeyLowerName_1 = "user_schematoken_exchange" + _KeyName_2 = "improved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" + _KeyLowerName_2 = "improved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" +) -var _KeyIndex = [...]uint16{0, 11, 28, 61, 81, 92, 106, 124, 144, 151, 174, 208, 232, 258, 266, 285, 308} - -const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactions_deprecatedimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" +var ( + _KeyIndex_0 = [...]uint8{0, 11, 28, 61} + _KeyIndex_1 = [...]uint8{0, 11, 25} + _KeyIndex_2 = [...]uint8{0, 20, 27, 50, 84, 108, 134, 142, 161, 184} +) func (i Key) String() string { - if i < 0 || i >= Key(len(_KeyIndex)-1) { + switch { + case 0 <= i && i <= 2: + return _KeyName_0[_KeyIndex_0[i]:_KeyIndex_0[i+1]] + case 4 <= i && i <= 5: + i -= 4 + return _KeyName_1[_KeyIndex_1[i]:_KeyIndex_1[i+1]] + case 7 <= i && i <= 15: + i -= 7 + return _KeyName_2[_KeyIndex_2[i]:_KeyIndex_2[i+1]] + default: return fmt.Sprintf("Key(%d)", i) } - return _KeyName[_KeyIndex[i]:_KeyIndex[i+1]] } // An "invalid array index" compiler error signifies that the constant values have changed. @@ -27,10 +44,8 @@ func _KeyNoOp() { _ = x[KeyUnspecified-(0)] _ = x[KeyLoginDefaultOrg-(1)] _ = x[KeyTriggerIntrospectionProjections-(2)] - _ = x[KeyLegacyIntrospection-(3)] _ = x[KeyUserSchema-(4)] _ = x[KeyTokenExchange-(5)] - _ = x[KeyActionsDeprecated-(6)] _ = x[KeyImprovedPerformance-(7)] _ = x[KeyWebKey-(8)] _ = x[KeyDebugOIDCParentError-(9)] @@ -42,60 +57,54 @@ func _KeyNoOp() { _ = x[KeyConsoleUseV2UserApi-(15)] } -var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActionsDeprecated, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyUserSchema, KeyTokenExchange, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} var _KeyNameToValueMap = map[string]Key{ - _KeyName[0:11]: KeyUnspecified, - _KeyLowerName[0:11]: KeyUnspecified, - _KeyName[11:28]: KeyLoginDefaultOrg, - _KeyLowerName[11:28]: KeyLoginDefaultOrg, - _KeyName[28:61]: KeyTriggerIntrospectionProjections, - _KeyLowerName[28:61]: KeyTriggerIntrospectionProjections, - _KeyName[61:81]: KeyLegacyIntrospection, - _KeyLowerName[61:81]: KeyLegacyIntrospection, - _KeyName[81:92]: KeyUserSchema, - _KeyLowerName[81:92]: KeyUserSchema, - _KeyName[92:106]: KeyTokenExchange, - _KeyLowerName[92:106]: KeyTokenExchange, - _KeyName[106:124]: KeyActionsDeprecated, - _KeyLowerName[106:124]: KeyActionsDeprecated, - _KeyName[124:144]: KeyImprovedPerformance, - _KeyLowerName[124:144]: KeyImprovedPerformance, - _KeyName[144:151]: KeyWebKey, - _KeyLowerName[144:151]: KeyWebKey, - _KeyName[151:174]: KeyDebugOIDCParentError, - _KeyLowerName[151:174]: KeyDebugOIDCParentError, - _KeyName[174:208]: KeyOIDCSingleV1SessionTermination, - _KeyLowerName[174:208]: KeyOIDCSingleV1SessionTermination, - _KeyName[208:232]: KeyDisableUserTokenEvent, - _KeyLowerName[208:232]: KeyDisableUserTokenEvent, - _KeyName[232:258]: KeyEnableBackChannelLogout, - _KeyLowerName[232:258]: KeyEnableBackChannelLogout, - _KeyName[258:266]: KeyLoginV2, - _KeyLowerName[258:266]: KeyLoginV2, - _KeyName[266:285]: KeyPermissionCheckV2, - _KeyLowerName[266:285]: KeyPermissionCheckV2, - _KeyName[285:308]: KeyConsoleUseV2UserApi, - _KeyLowerName[285:308]: KeyConsoleUseV2UserApi, + _KeyName_0[0:11]: KeyUnspecified, + _KeyLowerName_0[0:11]: KeyUnspecified, + _KeyName_0[11:28]: KeyLoginDefaultOrg, + _KeyLowerName_0[11:28]: KeyLoginDefaultOrg, + _KeyName_0[28:61]: KeyTriggerIntrospectionProjections, + _KeyLowerName_0[28:61]: KeyTriggerIntrospectionProjections, + _KeyName_1[0:11]: KeyUserSchema, + _KeyLowerName_1[0:11]: KeyUserSchema, + _KeyName_1[11:25]: KeyTokenExchange, + _KeyLowerName_1[11:25]: KeyTokenExchange, + _KeyName_2[0:20]: KeyImprovedPerformance, + _KeyLowerName_2[0:20]: KeyImprovedPerformance, + _KeyName_2[20:27]: KeyWebKey, + _KeyLowerName_2[20:27]: KeyWebKey, + _KeyName_2[27:50]: KeyDebugOIDCParentError, + _KeyLowerName_2[27:50]: KeyDebugOIDCParentError, + _KeyName_2[50:84]: KeyOIDCSingleV1SessionTermination, + _KeyLowerName_2[50:84]: KeyOIDCSingleV1SessionTermination, + _KeyName_2[84:108]: KeyDisableUserTokenEvent, + _KeyLowerName_2[84:108]: KeyDisableUserTokenEvent, + _KeyName_2[108:134]: KeyEnableBackChannelLogout, + _KeyLowerName_2[108:134]: KeyEnableBackChannelLogout, + _KeyName_2[134:142]: KeyLoginV2, + _KeyLowerName_2[134:142]: KeyLoginV2, + _KeyName_2[142:161]: KeyPermissionCheckV2, + _KeyLowerName_2[142:161]: KeyPermissionCheckV2, + _KeyName_2[161:184]: KeyConsoleUseV2UserApi, + _KeyLowerName_2[161:184]: KeyConsoleUseV2UserApi, } var _KeyNames = []string{ - _KeyName[0:11], - _KeyName[11:28], - _KeyName[28:61], - _KeyName[61:81], - _KeyName[81:92], - _KeyName[92:106], - _KeyName[106:124], - _KeyName[124:144], - _KeyName[144:151], - _KeyName[151:174], - _KeyName[174:208], - _KeyName[208:232], - _KeyName[232:258], - _KeyName[258:266], - _KeyName[266:285], - _KeyName[285:308], + _KeyName_0[0:11], + _KeyName_0[11:28], + _KeyName_0[28:61], + _KeyName_1[0:11], + _KeyName_1[11:25], + _KeyName_2[0:20], + _KeyName_2[20:27], + _KeyName_2[27:50], + _KeyName_2[50:84], + _KeyName_2[84:108], + _KeyName_2[108:134], + _KeyName_2[134:142], + _KeyName_2[142:161], + _KeyName_2[161:184], } // KeyString retrieves an enum value from the enum constants string name. diff --git a/internal/query/app.go b/internal/query/app.go index 5fed1e3ced..bc97c1807e 100644 --- a/internal/query/app.go +++ b/internal/query/app.go @@ -455,27 +455,6 @@ func (q *Queries) ProjectIDFromClientID(ctx context.Context, appID string) (id s return id, err } -func (q *Queries) ProjectByOIDCClientID(ctx context.Context, id string) (project *Project, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - stmt, scan := prepareProjectByOIDCAppQuery() - eq := sq.Eq{ - AppOIDCConfigColumnClientID.identifier(): id, - AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), - } - query, args, err := stmt.Where(eq).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-XhJi4", "Errors.Query.SQLStatement") - } - - err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { - project, err = scan(row) - return err - }, query, args...) - return project, err -} - func (q *Queries) AppByOIDCClientID(ctx context.Context, clientID string) (app *App, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -497,35 +476,6 @@ func (q *Queries) AppByOIDCClientID(ctx context.Context, clientID string) (app * return app, err } -func (q *Queries) AppByClientID(ctx context.Context, clientID string) (app *App, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - stmt, scan := prepareAppQuery(true) - eq := sq.Eq{ - AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), - AppColumnState.identifier(): domain.AppStateActive, - ProjectColumnState.identifier(): domain.ProjectStateActive, - OrgColumnState.identifier(): domain.OrgStateActive, - } - query, args, err := stmt.Where(sq.And{ - eq, - sq.Or{ - sq.Eq{AppOIDCConfigColumnClientID.identifier(): clientID}, - sq.Eq{AppAPIConfigColumnClientID.identifier(): clientID}, - }, - }).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Dfge2", "Errors.Query.SQLStatement") - } - - err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { - app, err = scan(row) - return err - }, query, args...) - return app, err -} - func (q *Queries) SearchApps(ctx context.Context, queries *AppSearchQueries, withOwnerRemoved bool) (apps *Apps, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -867,48 +817,6 @@ func prepareProjectIDByAppQuery() (sq.SelectBuilder, func(*sql.Row) (projectID s } } -func prepareProjectByOIDCAppQuery() (sq.SelectBuilder, func(*sql.Row) (*Project, error)) { - return sq.Select( - ProjectColumnID.identifier(), - ProjectColumnCreationDate.identifier(), - ProjectColumnChangeDate.identifier(), - ProjectColumnResourceOwner.identifier(), - ProjectColumnState.identifier(), - ProjectColumnSequence.identifier(), - ProjectColumnName.identifier(), - ProjectColumnProjectRoleAssertion.identifier(), - ProjectColumnProjectRoleCheck.identifier(), - ProjectColumnHasProjectCheck.identifier(), - ProjectColumnPrivateLabelingSetting.identifier(), - ).From(projectsTable.identifier()). - Join(join(AppColumnProjectID, ProjectColumnID)). - Join(join(AppOIDCConfigColumnAppID, AppColumnID)). - PlaceholderFormat(sq.Dollar), - func(row *sql.Row) (*Project, error) { - p := new(Project) - err := row.Scan( - &p.ID, - &p.CreationDate, - &p.ChangeDate, - &p.ResourceOwner, - &p.State, - &p.Sequence, - &p.Name, - &p.ProjectRoleAssertion, - &p.ProjectRoleCheck, - &p.HasProjectCheck, - &p.PrivateLabelingSetting, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, zerrors.ThrowNotFound(err, "QUERY-yxTMh", "Errors.Project.NotFound") - } - return nil, zerrors.ThrowInternal(err, "QUERY-dj2FF", "Errors.Internal") - } - return p, nil - } -} - func prepareProjectByAppQuery() (sq.SelectBuilder, func(*sql.Row) (*Project, error)) { return sq.Select( ProjectColumnID.identifier(), diff --git a/internal/query/authn_key.go b/internal/query/authn_key.go index ffbe38e7ae..5a1f49d63c 100644 --- a/internal/query/authn_key.go +++ b/internal/query/authn_key.go @@ -254,34 +254,6 @@ func (q *Queries) GetAuthNKeyByID(ctx context.Context, shouldTriggerBulk bool, i return key, err } -func (q *Queries) GetAuthNKeyPublicKeyByIDAndIdentifier(ctx context.Context, id string, identifier string) (key []byte, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - stmt, scan := prepareAuthNKeyPublicKeyQuery() - eq := sq.And{ - sq.Eq{ - AuthNKeyColumnID.identifier(): id, - AuthNKeyColumnIdentifier.identifier(): identifier, - AuthNKeyColumnEnabled.identifier(): true, - AuthNKeyColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), - }, - sq.Gt{ - AuthNKeyColumnExpiration.identifier(): time.Now(), - }, - } - query, args, err := stmt.Where(eq).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-DAb32", "Errors.Query.SQLStatement") - } - - err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { - key, err = scan(row) - return err - }, query, args...) - return key, err -} - func NewAuthNKeyResourceOwnerQuery(id string) (SearchQuery, error) { return NewTextQuery(AuthNKeyColumnResourceOwner, id, TextEquals) } @@ -429,26 +401,6 @@ func prepareAuthNKeyQuery() (sq.SelectBuilder, func(row *sql.Row) (*AuthNKey, er } } -func prepareAuthNKeyPublicKeyQuery() (sq.SelectBuilder, func(row *sql.Row) ([]byte, error)) { - return sq.Select( - AuthNKeyColumnPublicKey.identifier(), - ).From(authNKeyTable.identifier()). - PlaceholderFormat(sq.Dollar), - func(row *sql.Row) ([]byte, error) { - var publicKey []byte - err := row.Scan( - &publicKey, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, zerrors.ThrowNotFound(err, "QUERY-SDf32", "Errors.AuthNKey.NotFound") - } - return nil, zerrors.ThrowInternal(err, "QUERY-Bfs2a", "Errors.Internal") - } - return publicKey, nil - } -} - func prepareAuthNKeysDataQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeysData, error)) { return sq.Select( AuthNKeyColumnID.identifier(), diff --git a/internal/query/authn_key_test.go b/internal/query/authn_key_test.go index b7c66cc665..ce45185363 100644 --- a/internal/query/authn_key_test.go +++ b/internal/query/authn_key_test.go @@ -423,55 +423,6 @@ func Test_AuthNKeyPrepares(t *testing.T) { }, object: (*AuthNKey)(nil), }, - { - name: "prepareAuthNKeyPublicKeyQuery no result", - prepare: prepareAuthNKeyPublicKeyQuery, - want: want{ - sqlExpectations: mockQueriesScanErr( - regexp.QuoteMeta(prepareAuthNKeyPublicKeyStmt), - nil, - nil, - ), - err: func(err error) (error, bool) { - if !zerrors.IsNotFound(err) { - return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false - } - return nil, true - }, - }, - object: ([]byte)(nil), - }, - { - name: "prepareAuthNKeyPublicKeyQuery found", - prepare: prepareAuthNKeyPublicKeyQuery, - want: want{ - sqlExpectations: mockQuery( - regexp.QuoteMeta(prepareAuthNKeyPublicKeyStmt), - prepareAuthNKeyPublicKeyCols, - []driver.Value{ - []byte("publicKey"), - }, - ), - }, - object: []byte("publicKey"), - }, - { - name: "prepareAuthNKeyPublicKeyQuery sql err", - prepare: prepareAuthNKeyPublicKeyQuery, - want: want{ - sqlExpectations: mockQueryErr( - regexp.QuoteMeta(prepareAuthNKeyPublicKeyStmt), - sql.ErrConnDone, - ), - err: func(err error) (error, bool) { - if !errors.Is(err, sql.ErrConnDone) { - return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false - } - return nil, true - }, - }, - object: ([]byte)(nil), - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go index 4ec40dc9d5..501cfc4e9c 100644 --- a/internal/query/instance_features.go +++ b/internal/query/instance_features.go @@ -11,7 +11,6 @@ type InstanceFeatures struct { Details *domain.ObjectDetails LoginDefaultOrg FeatureSource[bool] TriggerIntrospectionProjections FeatureSource[bool] - LegacyIntrospection FeatureSource[bool] UserSchema FeatureSource[bool] TokenExchange FeatureSource[bool] ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] diff --git a/internal/query/instance_features_model.go b/internal/query/instance_features_model.go index 6a0abbb58c..7130044fbf 100644 --- a/internal/query/instance_features_model.go +++ b/internal/query/instance_features_model.go @@ -64,7 +64,6 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceResetEventType, feature_v2.InstanceLoginDefaultOrgEventType, feature_v2.InstanceTriggerIntrospectionProjectionsEventType, - feature_v2.InstanceLegacyIntrospectionEventType, feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, feature_v2.InstanceImprovedPerformanceEventType, @@ -94,7 +93,6 @@ func (m *InstanceFeaturesReadModel) populateFromSystem() bool { } m.instance.LoginDefaultOrg = m.system.LoginDefaultOrg m.instance.TriggerIntrospectionProjections = m.system.TriggerIntrospectionProjections - m.instance.LegacyIntrospection = m.system.LegacyIntrospection m.instance.UserSchema = m.system.UserSchema m.instance.TokenExchange = m.system.TokenExchange m.instance.ImprovedPerformance = m.system.ImprovedPerformance @@ -111,15 +109,12 @@ func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_ return err } switch key { - case feature.KeyUnspecified, - feature.KeyActionsDeprecated: + case feature.KeyUnspecified: return nil case feature.KeyLoginDefaultOrg: features.LoginDefaultOrg.set(level, event.Value) case feature.KeyTriggerIntrospectionProjections: features.TriggerIntrospectionProjections.set(level, event.Value) - case feature.KeyLegacyIntrospection: - features.LegacyIntrospection.set(level, event.Value) case feature.KeyUserSchema: features.UserSchema.set(level, event.Value) case feature.KeyTokenExchange: diff --git a/internal/query/instance_features_test.go b/internal/query/instance_features_test.go index d80a3b05fc..af662e4898 100644 --- a/internal/query/instance_features_test.go +++ b/internal/query/instance_features_test.go @@ -75,10 +75,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, }, }, { @@ -97,10 +93,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, @@ -120,10 +112,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelInstance, Value: true, }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelInstance, Value: false, @@ -146,10 +134,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, @@ -177,10 +161,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelInstance, Value: true, }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, @@ -199,10 +179,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, @@ -230,10 +206,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelInstance, Value: true, }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go index 34100a0d66..443353c2e5 100644 --- a/internal/query/projection/instance_features.go +++ b/internal/query/projection/instance_features.go @@ -68,10 +68,6 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.InstanceTriggerIntrospectionProjectionsEventType, Reduce: reduceInstanceSetFeature[bool], }, - { - Event: feature_v2.InstanceLegacyIntrospectionEventType, - Reduce: reduceInstanceSetFeature[bool], - }, { Event: feature_v2.InstanceUserSchemaEventType, Reduce: reduceInstanceSetFeature[bool], diff --git a/internal/query/projection/instance_features_test.go b/internal/query/projection/instance_features_test.go index 4a4a46727f..703a0ce00a 100644 --- a/internal/query/projection/instance_features_test.go +++ b/internal/query/projection/instance_features_test.go @@ -26,7 +26,7 @@ func TestInstanceFeaturesProjection_reduces(t *testing.T) { args: args{ event: getEvent( testEvent( - feature_v2.InstanceLegacyIntrospectionEventType, + feature_v2.SystemUserSchemaEventType, feature_v2.AggregateType, []byte(`{"value": true}`), ), eventstore.GenericEventMapper[feature_v2.SetEvent[bool]]), @@ -41,7 +41,7 @@ func TestInstanceFeaturesProjection_reduces(t *testing.T) { expectedStmt: "INSERT INTO projections.instance_features2 (instance_id, key, creation_date, change_date, sequence, value) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (instance_id, key) DO UPDATE SET (creation_date, change_date, sequence, value) = (projections.instance_features2.creation_date, EXCLUDED.change_date, EXCLUDED.sequence, EXCLUDED.value)", expectedArgs: []interface{}{ "agg-id", - "legacy_introspection", + "user_schema", anyArg{}, anyArg{}, uint64(15), diff --git a/internal/query/projection/system_features.go b/internal/query/projection/system_features.go index de54054e78..3f70f7dfa6 100644 --- a/internal/query/projection/system_features.go +++ b/internal/query/projection/system_features.go @@ -60,10 +60,6 @@ func (*systemFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.SystemTriggerIntrospectionProjectionsEventType, Reduce: reduceSystemSetFeature[bool], }, - { - Event: feature_v2.SystemLegacyIntrospectionEventType, - Reduce: reduceSystemSetFeature[bool], - }, { Event: feature_v2.SystemUserSchemaEventType, Reduce: reduceSystemSetFeature[bool], diff --git a/internal/query/projection/system_features_test.go b/internal/query/projection/system_features_test.go index 9bc19573cc..b64db7fb0a 100644 --- a/internal/query/projection/system_features_test.go +++ b/internal/query/projection/system_features_test.go @@ -24,7 +24,7 @@ func TestSystemFeaturesProjection_reduces(t *testing.T) { args: args{ event: getEvent( testEvent( - feature_v2.SystemLegacyIntrospectionEventType, + feature_v2.SystemUserSchemaEventType, feature_v2.AggregateType, []byte(`{"value": true}`), ), eventstore.GenericEventMapper[feature_v2.SetEvent[bool]]), @@ -38,7 +38,7 @@ func TestSystemFeaturesProjection_reduces(t *testing.T) { { expectedStmt: "INSERT INTO projections.system_features (key, creation_date, change_date, sequence, value) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (key) DO UPDATE SET (creation_date, change_date, sequence, value) = (projections.system_features.creation_date, EXCLUDED.change_date, EXCLUDED.sequence, EXCLUDED.value)", expectedArgs: []interface{}{ - "legacy_introspection", + "user_schema", anyArg{}, anyArg{}, uint64(15), diff --git a/internal/query/system_features.go b/internal/query/system_features.go index dcbbb7d6fe..8c340ce739 100644 --- a/internal/query/system_features.go +++ b/internal/query/system_features.go @@ -22,7 +22,6 @@ type SystemFeatures struct { LoginDefaultOrg FeatureSource[bool] TriggerIntrospectionProjections FeatureSource[bool] - LegacyIntrospection FeatureSource[bool] UserSchema FeatureSource[bool] TokenExchange FeatureSource[bool] ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] diff --git a/internal/query/system_features_model.go b/internal/query/system_features_model.go index 69e1f35968..f91bc7d1e9 100644 --- a/internal/query/system_features_model.go +++ b/internal/query/system_features_model.go @@ -57,7 +57,6 @@ func (m *SystemFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemResetEventType, feature_v2.SystemLoginDefaultOrgEventType, feature_v2.SystemTriggerIntrospectionProjectionsEventType, - feature_v2.SystemLegacyIntrospectionEventType, feature_v2.SystemUserSchemaEventType, feature_v2.SystemTokenExchangeEventType, feature_v2.SystemImprovedPerformanceEventType, @@ -81,15 +80,12 @@ func reduceSystemFeatureSet[T any](features *SystemFeatures, event *feature_v2.S return err } switch key { - case feature.KeyUnspecified, - feature.KeyActionsDeprecated: + case feature.KeyUnspecified: return nil case feature.KeyLoginDefaultOrg: features.LoginDefaultOrg.set(level, event.Value) case feature.KeyTriggerIntrospectionProjections: features.TriggerIntrospectionProjections.set(level, event.Value) - case feature.KeyLegacyIntrospection: - features.LegacyIntrospection.set(level, event.Value) case feature.KeyUserSchema: features.UserSchema.set(level, event.Value) case feature.KeyTokenExchange: diff --git a/internal/query/system_features_test.go b/internal/query/system_features_test.go index 5a58ac23d7..da59ceb549 100644 --- a/internal/query/system_features_test.go +++ b/internal/query/system_features_test.go @@ -53,10 +53,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, @@ -75,10 +71,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelSystem, Value: false, @@ -97,10 +89,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, @@ -127,10 +115,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, @@ -149,10 +133,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, @@ -179,10 +159,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, diff --git a/internal/query/user_grant.go b/internal/query/user_grant.go index ebd4ab7c0c..05d80fe381 100644 --- a/internal/query/user_grant.go +++ b/internal/query/user_grant.go @@ -78,14 +78,6 @@ func NewUserGrantProjectIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(UserGrantProjectID, id, TextEquals) } -func NewUserGrantProjectIDsSearchQuery(ids []string) (SearchQuery, error) { - list := make([]interface{}, len(ids)) - for i, value := range ids { - list[i] = value - } - return NewListQuery(UserGrantProjectID, list, ListIn) -} - func NewUserGrantProjectOwnerSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(ProjectColumnResourceOwner, id, TextEquals) } diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go index 00618f56c2..62fa568fca 100644 --- a/internal/repository/feature/feature_v2/eventstore.go +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -9,7 +9,6 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, SystemResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, SystemLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) - eventstore.RegisterFilterEventMapper(AggregateType, SystemLegacyIntrospectionEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) @@ -22,7 +21,6 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, InstanceResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) - eventstore.RegisterFilterEventMapper(AggregateType, InstanceLegacyIntrospectionEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go index d5e8941df2..f75fae618b 100644 --- a/internal/repository/feature/feature_v2/feature.go +++ b/internal/repository/feature/feature_v2/feature.go @@ -14,7 +14,6 @@ var ( SystemResetEventType = resetEventTypeFromFeature(feature.LevelSystem) SystemLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginDefaultOrg) SystemTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTriggerIntrospectionProjections) - SystemLegacyIntrospectionEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLegacyIntrospection) SystemUserSchemaEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyUserSchema) SystemTokenExchangeEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTokenExchange) SystemImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyImprovedPerformance) @@ -27,7 +26,6 @@ var ( InstanceResetEventType = resetEventTypeFromFeature(feature.LevelInstance) InstanceLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginDefaultOrg) InstanceTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTriggerIntrospectionProjections) - InstanceLegacyIntrospectionEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLegacyIntrospection) InstanceUserSchemaEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyUserSchema) InstanceTokenExchangeEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTokenExchange) InstanceImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyImprovedPerformance) diff --git a/proto/zitadel/feature/v2/instance.proto b/proto/zitadel/feature/v2/instance.proto index fe8d3f7a39..0455befb46 100644 --- a/proto/zitadel/feature/v2/instance.proto +++ b/proto/zitadel/feature/v2/instance.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2;feature"; message SetInstanceFeaturesRequest{ - reserved 6; - reserved "actions"; + reserved 3, 6; + reserved "oidc_legacy_introspection", "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -25,12 +25,6 @@ message SetInstanceFeaturesRequest{ description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; } ]; - optional bool oidc_legacy_introspection = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -131,8 +125,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { - reserved 7; - reserved "actions"; + reserved 4, 7; + reserved "oidc_legacy_introspection", "actions"; zitadel.object.v2.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -148,13 +142,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag oidc_legacy_introspection = 4 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2/system.proto b/proto/zitadel/feature/v2/system.proto index d222e2a90c..ac39e62f09 100644 --- a/proto/zitadel/feature/v2/system.proto +++ b/proto/zitadel/feature/v2/system.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2;feature"; message SetSystemFeaturesRequest{ - reserved 6; - reserved "actions"; + reserved 3, 6; + reserved "oidc_legacy_introspection", "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -27,13 +27,6 @@ message SetSystemFeaturesRequest{ } ]; - optional bool oidc_legacy_introspection = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -105,8 +98,8 @@ message ResetSystemFeaturesResponse { message GetSystemFeaturesRequest {} message GetSystemFeaturesResponse { - reserved 7; - reserved "actions"; + reserved 4, 7; + reserved "oidc_legacy_introspection", "actions"; zitadel.object.v2.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -122,13 +115,6 @@ message GetSystemFeaturesResponse { } ]; - FeatureFlag oidc_legacy_introspection = 4 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2beta/instance.proto b/proto/zitadel/feature/v2beta/instance.proto index 7717dd7556..8028305fe4 100644 --- a/proto/zitadel/feature/v2beta/instance.proto +++ b/proto/zitadel/feature/v2beta/instance.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2beta/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; message SetInstanceFeaturesRequest{ - reserved 6; - reserved "actions"; + reserved 3, 6; + reserved "oidc_legacy_introspection", "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -25,12 +25,6 @@ message SetInstanceFeaturesRequest{ description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; } ]; - optional bool oidc_legacy_introspection = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -97,8 +91,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { - reserved 7; - reserved "actions"; + reserved 4, 7; + reserved "oidc_legacy_introspection", "actions"; zitadel.object.v2beta.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -114,13 +108,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag oidc_legacy_introspection = 4 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2beta/system.proto b/proto/zitadel/feature/v2beta/system.proto index 624e68ec79..95bf71da9b 100644 --- a/proto/zitadel/feature/v2beta/system.proto +++ b/proto/zitadel/feature/v2beta/system.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2beta/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; message SetSystemFeaturesRequest{ - reserved 6; - reserved "actions"; + reserved 3, 6; + reserved "oidc_legacy_introspection", "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -27,13 +27,6 @@ message SetSystemFeaturesRequest{ } ]; - optional bool oidc_legacy_introspection = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -78,8 +71,8 @@ message ResetSystemFeaturesResponse { message GetSystemFeaturesRequest {} message GetSystemFeaturesResponse { - reserved 7; - reserved "actions"; + reserved 4, 7; + reserved "oidc_legacy_introspection", "actions"; zitadel.object.v2beta.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -95,13 +88,6 @@ message GetSystemFeaturesResponse { } ]; - FeatureFlag oidc_legacy_introspection = 4 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; From 016676e1dc21f031eb3819179f7c2827ad30ca3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Thu, 26 Jun 2025 19:17:45 +0300 Subject: [PATCH 09/66] chore(oidc): graduate webkey to stable (#10122) # Which Problems Are Solved Stabilize the usage of webkeys. # How the Problems Are Solved - Remove all legacy signing key code from the OIDC API - Remove the webkey feature flag from proto - Remove the webkey feature flag from console - Cleanup documentation # Additional Changes - Resolved some canonical header linter errors in OIDC - Use the constant for `projections.lock` in the saml package. # Additional Context - Closes #10029 - After #10105 - After #10061 --- cmd/start/start.go | 1 - .../components/features/features.component.ts | 1 - console/src/assets/i18n/bg.json | 2 - console/src/assets/i18n/cs.json | 2 - console/src/assets/i18n/de.json | 2 - console/src/assets/i18n/en.json | 2 - console/src/assets/i18n/es.json | 2 - console/src/assets/i18n/fr.json | 2 - console/src/assets/i18n/hu.json | 2 - console/src/assets/i18n/id.json | 2 - console/src/assets/i18n/it.json | 2 - console/src/assets/i18n/ja.json | 2 - console/src/assets/i18n/ko.json | 2 - console/src/assets/i18n/mk.json | 2 - console/src/assets/i18n/nl.json | 2 - console/src/assets/i18n/pl.json | 2 - console/src/assets/i18n/pt.json | 2 - console/src/assets/i18n/ro.json | 2 - console/src/assets/i18n/ru.json | 2 - console/src/assets/i18n/sv.json | 2 - console/src/assets/i18n/zh.json | 2 - .../guides/integrate/login/oidc/webkeys.md | 7 - internal/api/grpc/feature/v2/converter.go | 2 - .../api/grpc/feature/v2/converter_test.go | 10 - internal/api/grpc/feature/v2beta/converter.go | 2 - .../api/grpc/feature/v2beta/converter_test.go | 10 - .../webkey_integration_test.go | 62 +-- internal/api/grpc/webkey/v2beta/webkey.go | 21 - internal/api/oidc/access_token.go | 2 +- internal/api/oidc/auth_request_converter.go | 9 +- .../api/oidc/integration_test/keys_test.go | 41 +- .../oidc/integration_test/userinfo_test.go | 10 +- internal/api/oidc/key.go | 210 +------- internal/api/oidc/op.go | 12 +- internal/api/oidc/server.go | 2 +- internal/api/oidc/server_test.go | 89 ---- internal/api/oidc/token.go | 21 +- internal/api/saml/certificate.go | 3 +- .../eventstore/token_verifier.go | 29 +- internal/command/instance_features.go | 23 - internal/command/instance_features_model.go | 5 - internal/command/key_pair.go | 25 - internal/crypto/rsa.go | 8 - internal/feature/feature.go | 4 +- internal/feature/key_enumer.go | 65 +-- .../handlers/back_channel_logout.go | 18 +- .../handlers/mock/commands.mock.go | 113 ++--- .../handlers/mock/queries.mock.go | 137 +++--- .../notification/handlers/mock/queue.mock.go | 11 +- internal/notification/handlers/queries.go | 2 - internal/query/instance_features.go | 1 - internal/query/instance_features_model.go | 3 - internal/query/key.go | 317 ------------ internal/query/key_test.go | 453 ------------------ .../query/projection/instance_features.go | 4 - .../feature/feature_v2/eventstore.go | 1 - .../repository/feature/feature_v2/feature.go | 1 - proto/zitadel/feature/v2/instance.proto | 22 +- proto/zitadel/feature/v2beta/instance.proto | 22 +- 59 files changed, 203 insertions(+), 1614 deletions(-) delete mode 100644 internal/query/key_test.go diff --git a/cmd/start/start.go b/cmd/start/start.go index 8820480f0c..3c3b5cb3e0 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -550,7 +550,6 @@ func startAPIs( keys.OIDC, keys.OIDCKey, eventstore, - dbClient, userAgentInterceptor, instanceInterceptor.Handler, limitingAccessInterceptor, diff --git a/console/src/app/components/features/features.component.ts b/console/src/app/components/features/features.component.ts index 70e038bae8..ace2788fcf 100644 --- a/console/src/app/components/features/features.component.ts +++ b/console/src/app/components/features/features.component.ts @@ -38,7 +38,6 @@ const FEATURE_KEYS = [ 'oidcTriggerIntrospectionProjections', 'permissionCheckV2', 'userSchema', - 'webKey', ] as const; export type ToggleState = { source: Source; enabled: boolean }; diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index 7d594e8318..50c0d66027 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1641,8 +1641,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout имплементира OpenID Connect Back-Channel Logout 1.0 и може да се използва за уведомяване на клиентите за прекратяване на сесията при OpenID доставчика.", "PERMISSIONCHECKV2": "Проверка на разрешения V2", "PERMISSIONCHECKV2_DESCRIPTION": "Ако флагът е активиран, ще можете да използвате новия API и неговите функции.", - "WEBKEY": "Уеб ключ", - "WEBKEY_DESCRIPTION": "Ако флагът е активиран, ще можете да използвате новия API и неговите функции.", "STATES": { "INHERITED": "Наследено", "ENABLED": "Активирано", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 2ee5d9d0c5..5b4547ccb4 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout implementuje OpenID Connect Back-Channel Logout 1.0 a může být použit k informování klientů o ukončení relace u poskytovatele OpenID.", "PERMISSIONCHECKV2": "Kontrola oprávnění V2", "PERMISSIONCHECKV2_DESCRIPTION": "Pokud je příznak povolen, budete moci používat nový API a jeho funkce.", - "WEBKEY": "Webový klíč", - "WEBKEY_DESCRIPTION": "Pokud je příznak povolen, budete moci používat nový API a jeho funkce.", "STATES": { "INHERITED": "Děděno", "ENABLED": "Povoleno", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index b8f8363d13..8fec6498ec 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Der Back-Channel-Logout implementiert OpenID Connect Back-Channel Logout 1.0 und kann verwendet werden, um Clients über die Beendigung der Sitzung beim OpenID-Provider zu benachrichtigen.", "PERMISSIONCHECKV2": "Berechtigungsprüfung V2", "PERMISSIONCHECKV2_DESCRIPTION": "Wenn die Flagge aktiviert ist, können Sie die neue API und ihre Funktionen verwenden.", - "WEBKEY": "Web-Schlüssel", - "WEBKEY_DESCRIPTION": "Wenn die Flagge aktiviert ist, können Sie die neue API und ihre Funktionen verwenden.", "STATES": { "INHERITED": "Erben", "ENABLED": "Aktiviert", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index fe152acb81..95fd55bfef 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1645,8 +1645,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "The Back-Channel Logout implements OpenID Connect Back-Channel Logout 1.0 and can be used to notify clients about session termination at the OpenID Provider.", "PERMISSIONCHECKV2": "Permission Check V2", "PERMISSIONCHECKV2_DESCRIPTION": "If the flag is enabled, you'll be able to use the new API and its features.", - "WEBKEY": "Web Key", - "WEBKEY_DESCRIPTION": "If the flag is enabled, you'll be able to use the new API and its features.", "STATES": { "INHERITED": "Inherit", "ENABLED": "Enabled", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index fff111fd1d..359aa4a0b5 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1643,8 +1643,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "El Back-Channel Logout implementa OpenID Connect Back-Channel Logout 1.0 y se puede usar para notificar a los clientes sobre la terminación de la sesión en el proveedor de OpenID.", "PERMISSIONCHECKV2": "Verificación de permisos V2", "PERMISSIONCHECKV2_DESCRIPTION": "Si la bandera está habilitada, podrá usar la nueva API y sus funciones.", - "WEBKEY": "Clave web", - "WEBKEY_DESCRIPTION": "Si la bandera está habilitada, podrá usar la nueva API y sus funciones.", "STATES": { "INHERITED": "Heredado", "ENABLED": "Habilitado", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index fc5cf69602..0864a2f8c0 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Le Back-Channel Logout implémente OpenID Connect Back-Channel Logout 1.0 et peut être utilisé pour notifier les clients de la fin de session chez le fournisseur OpenID.", "PERMISSIONCHECKV2": "Vérification des permissions V2", "PERMISSIONCHECKV2_DESCRIPTION": "Si le drapeau est activé, vous pourrez utiliser la nouvelle API et ses fonctionnalités.", - "WEBKEY": "Clé web", - "WEBKEY_DESCRIPTION": "Si le drapeau est activé, vous pourrez utiliser la nouvelle API et ses fonctionnalités.", "STATES": { "INHERITED": "Hérité", "ENABLED": "Activé", diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index d7dd32b15a..a87122dc52 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -1640,8 +1640,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "A Back-Channel Logout megvalósítja az OpenID Connect Back-Channel Logout 1.0-t, és használható az ügyfelek értesítésére a munkamenet befejezéséről az OpenID szolgáltatónál.", "PERMISSIONCHECKV2": "Engedély ellenőrzés V2", "PERMISSIONCHECKV2_DESCRIPTION": "Ha a zászló engedélyezve van, használhatja az új API-t és annak funkcióit.", - "WEBKEY": "Webkulcs", - "WEBKEY_DESCRIPTION": "Ha a zászló engedélyezve van, használhatja az új API-t és annak funkcióit.", "STATES": { "INHERITED": "Örököl", "ENABLED": "Engedélyezve", diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index 3dcd7b36b7..3f245d03c5 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -1513,8 +1513,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "The Back-Channel Logout implements OpenID Connect Back-Channel Logout 1.0 and can be used to notify clients about session termination at the OpenID Provider.", "PERMISSIONCHECKV2": "Permission Check V2", "PERMISSIONCHECKV2_DESCRIPTION": "If the flag is enabled, you'll be able to use the new API and its features.", - "WEBKEY": "Web Key", - "WEBKEY_DESCRIPTION": "If the flag is enabled, you'll be able to use the new API and its features.", "STATES": { "INHERITED": "Mewarisi", "ENABLED": "Diaktifkan", "DISABLED": "Dengan disabilitas" }, "INHERITED_DESCRIPTION": "Ini menetapkan nilai ke nilai default sistem.", "INHERITEDINDICATOR_DESCRIPTION": { diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index da1df2ba38..e127281433 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Il Back-Channel Logout implementa OpenID Connect Back-Channel Logout 1.0 e può essere utilizzato per notificare ai client la terminazione della sessione presso il provider OpenID.", "PERMISSIONCHECKV2": "Controllo permessi V2", "PERMISSIONCHECKV2_DESCRIPTION": "Se il flag è abilitato, potrai utilizzare la nuova API e le sue funzionalità.", - "WEBKEY": "Chiave Web", - "WEBKEY_DESCRIPTION": "Se il flag è abilitato, potrai utilizzare la nuova API e le sue funzionalità.", "STATES": { "INHERITED": "Predefinito", "ENABLED": "Abilitato", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 1828a8ada8..250561e938 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "バックチャネルログアウトは OpenID Connect バックチャネルログアウト 1.0 を実装し、OpenID プロバイダーでのセッション終了についてクライアントに通知するために使用できます。", "PERMISSIONCHECKV2": "権限チェック V2", "PERMISSIONCHECKV2_DESCRIPTION": "フラグが有効になっている場合、新しい API とその機能を使用できます。", - "WEBKEY": "ウェブキー", - "WEBKEY_DESCRIPTION": "フラグが有効になっている場合、新しい API とその機能を使用できます。", "STATES": { "INHERITED": "継承", "ENABLED": "有効", diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index af5fa65972..716375941d 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "백채널 로그아웃은 OpenID Connect 백채널 로그아웃 1.0을 구현하며, OpenID 제공자에서 세션 종료에 대해 클라이언트에게 알리는 데 사용할 수 있습니다.", "PERMISSIONCHECKV2": "권한 확인 V2", "PERMISSIONCHECKV2_DESCRIPTION": "플래그가 활성화되면 새로운 API와 그 기능을 사용할 수 있습니다.", - "WEBKEY": "웹 키", - "WEBKEY_DESCRIPTION": "플래그가 활성화되면 새로운 API와 그 기능을 사용할 수 있습니다.", "STATES": { "INHERITED": "상속", "ENABLED": "활성화됨", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index 1e85e06928..39836f5dfc 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1643,8 +1643,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout имплементира OpenID Connect Back-Channel Logout 1.0 и може да се користи за известување на клиентите за завршување на сесијата кај OpenID провајдерот.", "PERMISSIONCHECKV2": "Проверка на дозволи V2", "PERMISSIONCHECKV2_DESCRIPTION": "Ако знамето е овозможено, ќе можете да ја користите новата API и нејзините функции.", - "WEBKEY": "Веб клуч", - "WEBKEY_DESCRIPTION": "Ако знамето е овозможено, ќе можете да ја користите новата API и нејзините функции.", "STATES": { "INHERITED": "Наследи", "ENABLED": "Овозможено", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index c3de881784..c49867aa3e 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "De Back-Channel Logout implementeert OpenID Connect Back-Channel Logout 1.0 en kan worden gebruikt om clients te informeren over het beëindigen van de sessie bij de OpenID-provider.", "PERMISSIONCHECKV2": "Permissiecontrole V2", "PERMISSIONCHECKV2_DESCRIPTION": "Als de vlag is ingeschakeld, kunt u de nieuwe API en de bijbehorende functies gebruiken.", - "WEBKEY": "Websleutel", - "WEBKEY_DESCRIPTION": "Als de vlag is ingeschakeld, kunt u de nieuwe API en de bijbehorende functies gebruiken.", "STATES": { "INHERITED": "Overgenomen", "ENABLED": "Ingeschakeld", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index ca5476463d..abf2e1ba8a 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1641,8 +1641,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout implementuje OpenID Connect Back-Channel Logout 1.0 i może być używany do powiadamiania klientów o zakończeniu sesji u dostawcy OpenID.", "PERMISSIONCHECKV2": "Sprawdzanie uprawnień V2", "PERMISSIONCHECKV2_DESCRIPTION": "Jeśli flaga jest włączona, będziesz mógł korzystać z nowego API i jego funkcji.", - "WEBKEY": "Klucz Web", - "WEBKEY_DESCRIPTION": "Jeśli flaga jest włączona, będziesz mógł korzystać z nowego API i jego funkcji.", "STATES": { "INHERITED": "Dziedziczony", "ENABLED": "Włączony", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 7c68a4cada..8b858bd44e 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1643,8 +1643,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "O Logout de Back-Channel implementa o OpenID Connect Back-Channel Logout 1.0 e pode ser usado para notificar os clientes sobre a terminação da sessão no Provedor de OpenID.", "PERMISSIONCHECKV2": "Verificação de Permissão V2", "PERMISSIONCHECKV2_DESCRIPTION": "Se a bandeira estiver ativada, você poderá usar a nova API e seus recursos.", - "WEBKEY": "Chave Web", - "WEBKEY_DESCRIPTION": "Se a bandeira estiver ativada, você poderá usar a nova API e seus recursos.", "STATES": { "INHERITED": "Herdade", "ENABLED": "Habilitado", diff --git a/console/src/assets/i18n/ro.json b/console/src/assets/i18n/ro.json index 0e0802a17c..d2f51a81e0 100644 --- a/console/src/assets/i18n/ro.json +++ b/console/src/assets/i18n/ro.json @@ -1640,8 +1640,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Logout-ul Back-Channel implementează OpenID Connect Back-Channel Logout 1.0 și poate fi folosit pentru a notifica clienții despre terminarea sesiunii la Producătorul OpenID.", "PERMISSIONCHECKV2": "Verificare Permisiuni V2", "PERMISSIONCHECKV2_DESCRIPTION": "Dacă steagul este activat, veți putea folosi noua API și funcțiile sale.", - "WEBKEY": "Cheie Web", - "WEBKEY_DESCRIPTION": "Dacă steagul este activat, veți putea folosi noua API și funcțiile sale.", "STATES": { "INHERITED": "Moșteniți", "ENABLED": "Activat", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 8e06568a82..3070b311e7 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -1695,8 +1695,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout реализует OpenID Connect Back-Channel Logout 1.0 и может использоваться для уведомления клиентов о завершении сеанса у поставщика OpenID.", "PERMISSIONCHECKV2": "Проверка Разрешений V2", "PERMISSIONCHECKV2_DESCRIPTION": "Если флаг включен, вы сможете использовать новый API и его функции.", - "WEBKEY": "Веб-ключ", - "WEBKEY_DESCRIPTION": "Если флаг включен, вы сможете использовать новый API и его функции.", "STATES": { "INHERITED": "Наследовать", "ENABLED": "Включено", diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 1b80021a67..8f03501054 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -1646,8 +1646,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout implementerar OpenID Connect Back-Channel Logout 1.0 och kan användas för att meddela klienter om sessionens avslutning hos OpenID-leverantören.", "PERMISSIONCHECKV2": "Behörighetskontroll V2", "PERMISSIONCHECKV2_DESCRIPTION": "Om flaggan är aktiverad kan du använda den nya API:n och dess funktioner.", - "WEBKEY": "Webbnyckel", - "WEBKEY_DESCRIPTION": "Om flaggan är aktiverad kan du använda den nya API:n och dess funktioner.", "STATES": { "INHERITED": "Ärv", "ENABLED": "Aktiverad", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 9565b61eca..0431405979 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel 注销实现了 OpenID Connect Back-Channel Logout 1.0,可用于通知客户端在 OpenID 提供商处终止会话。", "PERMISSIONCHECKV2": "权限检查 V2", "PERMISSIONCHECKV2_DESCRIPTION": "如果启用该标志,您将能够使用新的 API 及其功能。", - "WEBKEY": "Web 密钥", - "WEBKEY_DESCRIPTION": "如果启用该标志,您将能够使用新的 API 及其功能。", "STATES": { "INHERITED": "继承", "ENABLED": "已启用", diff --git a/docs/docs/guides/integrate/login/oidc/webkeys.md b/docs/docs/guides/integrate/login/oidc/webkeys.md index 62f62a90e0..288284fefc 100644 --- a/docs/docs/guides/integrate/login/oidc/webkeys.md +++ b/docs/docs/guides/integrate/login/oidc/webkeys.md @@ -20,13 +20,6 @@ JWT access tokens, instead of [introspection](/docs/apis/openidoauth/endpoints#i ZITADEL uses public key verification when API calls are made or when the userInfo or introspection endpoints are called with a JWT access token. -:::info -Web keys are an [experimental](/docs/support/software-release-cycles-support#beta) feature. Be sure to enable the `web_key` [feature](/docs/apis/resources/feature_service_v2/feature-service-set-instance-features) before using it. - -The documentation describes the state of the feature in ZITADEL V3. -Test the feature and add improvement or bug reports directly to the [github repository](https://github.com/zitadel/zitadel) or let us know your general feedback in the [discord thread](https://discord.com/channels/927474939156643850/1329100936127320175/threads/1332344892629717075)! -::: - ### JSON Web Key ZITADEL implements the [RFC7517 - JSON Web Key (JWK)](https://www.rfc-editor.org/rfc/rfc7517) format for storage and distribution of public keys. diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index 56d3009457..1f0a3b21e7 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -58,7 +58,6 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) (*com UserSchema: req.UserSchema, TokenExchange: req.OidcTokenExchange, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - WebKey: req.WebKey, DebugOIDCParentError: req.DebugOidcParentError, OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, DisableUserTokenEvent: req.DisableUserTokenEvent, @@ -77,7 +76,6 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat UserSchema: featureSourceToFlagPb(&f.UserSchema), OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - WebKey: featureSourceToFlagPb(&f.WebKey), DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index b77ed438f5..d09f1839ba 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -153,7 +153,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { UserSchema: gu.Ptr(true), OidcTokenExchange: gu.Ptr(true), ImprovedPerformance: nil, - WebKey: gu.Ptr(true), DebugOidcParentError: gu.Ptr(true), OidcSingleV1SessionTermination: gu.Ptr(true), EnableBackChannelLogout: gu.Ptr(true), @@ -169,7 +168,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { UserSchema: gu.Ptr(true), TokenExchange: gu.Ptr(true), ImprovedPerformance: nil, - WebKey: gu.Ptr(true), DebugOIDCParentError: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(true), EnableBackChannelLogout: gu.Ptr(true), @@ -211,10 +209,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: []feature.ImprovedPerformanceType{feature.ImprovedPerformanceTypeOrgByID}, }, - WebKey: query.FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, OIDCSingleV1SessionTermination: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -265,10 +259,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { ExecutionPaths: []feature_pb.ImprovedPerformance{feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID}, Source: feature_pb.Source_SOURCE_SYSTEM, }, - WebKey: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_INSTANCE, - }, DebugOidcParentError: &feature_pb.FeatureFlag{ Enabled: false, Source: feature_pb.Source_SOURCE_UNSPECIFIED, diff --git a/internal/api/grpc/feature/v2beta/converter.go b/internal/api/grpc/feature/v2beta/converter.go index 406146fdbe..8927b16e29 100644 --- a/internal/api/grpc/feature/v2beta/converter.go +++ b/internal/api/grpc/feature/v2beta/converter.go @@ -38,7 +38,6 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *comm UserSchema: req.UserSchema, TokenExchange: req.OidcTokenExchange, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - WebKey: req.WebKey, DebugOIDCParentError: req.DebugOidcParentError, OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, } @@ -52,7 +51,6 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat UserSchema: featureSourceToFlagPb(&f.UserSchema), OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - WebKey: featureSourceToFlagPb(&f.WebKey), DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), } diff --git a/internal/api/grpc/feature/v2beta/converter_test.go b/internal/api/grpc/feature/v2beta/converter_test.go index 2395574733..5fdb5e993e 100644 --- a/internal/api/grpc/feature/v2beta/converter_test.go +++ b/internal/api/grpc/feature/v2beta/converter_test.go @@ -111,7 +111,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { UserSchema: gu.Ptr(true), OidcTokenExchange: gu.Ptr(true), ImprovedPerformance: nil, - WebKey: gu.Ptr(true), OidcSingleV1SessionTermination: gu.Ptr(true), } want := &command.InstanceFeatures{ @@ -120,7 +119,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { UserSchema: gu.Ptr(true), TokenExchange: gu.Ptr(true), ImprovedPerformance: nil, - WebKey: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(true), } got := instanceFeaturesToCommand(arg) @@ -154,10 +152,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: []feature.ImprovedPerformanceType{feature.ImprovedPerformanceTypeOrgByID}, }, - WebKey: query.FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, OIDCSingleV1SessionTermination: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -189,10 +183,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { ExecutionPaths: []feature_pb.ImprovedPerformance{feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID}, Source: feature_pb.Source_SOURCE_SYSTEM, }, - WebKey: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_INSTANCE, - }, DebugOidcParentError: &feature_pb.FeatureFlag{ Enabled: false, Source: feature_pb.Source_SOURCE_UNSPECIFIED, diff --git a/internal/api/grpc/webkey/v2beta/integration_test/webkey_integration_test.go b/internal/api/grpc/webkey/v2beta/integration_test/webkey_integration_test.go index 002669c233..0cbf629b43 100644 --- a/internal/api/grpc/webkey/v2beta/integration_test/webkey_integration_test.go +++ b/internal/api/grpc/webkey/v2beta/integration_test/webkey_integration_test.go @@ -12,11 +12,9 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" - "github.com/zitadel/zitadel/pkg/grpc/feature/v2" webkey "github.com/zitadel/zitadel/pkg/grpc/webkey/v2beta" ) @@ -33,34 +31,8 @@ func TestMain(m *testing.M) { }()) } -func TestServer_Feature_Disabled(t *testing.T) { - instance, iamCtx, _ := createInstance(t, false) - client := instance.Client.WebKeyV2Beta - - t.Run("CreateWebKey", func(t *testing.T) { - _, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{}) - assertFeatureDisabledError(t, err) - }) - t.Run("ActivateWebKey", func(t *testing.T) { - _, err := client.ActivateWebKey(iamCtx, &webkey.ActivateWebKeyRequest{ - Id: "1", - }) - assertFeatureDisabledError(t, err) - }) - t.Run("DeleteWebKey", func(t *testing.T) { - _, err := client.DeleteWebKey(iamCtx, &webkey.DeleteWebKeyRequest{ - Id: "1", - }) - assertFeatureDisabledError(t, err) - }) - t.Run("ListWebKeys", func(t *testing.T) { - _, err := client.ListWebKeys(iamCtx, &webkey.ListWebKeysRequest{}) - assertFeatureDisabledError(t, err) - }) -} - func TestServer_ListWebKeys(t *testing.T) { - instance, iamCtx, creationDate := createInstance(t, true) + instance, iamCtx, creationDate := createInstance(t) // After the feature is first enabled, we can expect 2 generated keys with the default config. checkWebKeyListState(iamCtx, t, instance, 2, "", &webkey.WebKey_Rsa{ Rsa: &webkey.RSA{ @@ -71,7 +43,7 @@ func TestServer_ListWebKeys(t *testing.T) { } func TestServer_CreateWebKey(t *testing.T) { - instance, iamCtx, creationDate := createInstance(t, true) + instance, iamCtx, creationDate := createInstance(t) client := instance.Client.WebKeyV2Beta _, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{ @@ -93,7 +65,7 @@ func TestServer_CreateWebKey(t *testing.T) { } func TestServer_ActivateWebKey(t *testing.T) { - instance, iamCtx, creationDate := createInstance(t, true) + instance, iamCtx, creationDate := createInstance(t) client := instance.Client.WebKeyV2Beta resp, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{ @@ -120,7 +92,7 @@ func TestServer_ActivateWebKey(t *testing.T) { } func TestServer_DeleteWebKey(t *testing.T) { - instance, iamCtx, creationDate := createInstance(t, true) + instance, iamCtx, creationDate := createInstance(t) client := instance.Client.WebKeyV2Beta keyIDs := make([]string, 2) @@ -197,40 +169,22 @@ func TestServer_DeleteWebKey(t *testing.T) { }, creationDate) } -func createInstance(t *testing.T, enableFeature bool) (*integration.Instance, context.Context, *timestamppb.Timestamp) { +func createInstance(t *testing.T) (*integration.Instance, context.Context, *timestamppb.Timestamp) { instance := integration.NewInstance(CTX) creationDate := timestamppb.Now() iamCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - if enableFeature { - _, err := instance.Client.FeatureV2.SetInstanceFeatures(iamCTX, &feature.SetInstanceFeaturesRequest{ - WebKey: proto.Bool(true), - }) - require.NoError(t, err) - } - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamCTX, time.Minute) assert.EventuallyWithT(t, func(collect *assert.CollectT) { resp, err := instance.Client.WebKeyV2Beta.ListWebKeys(iamCTX, &webkey.ListWebKeysRequest{}) - if enableFeature { - assert.NoError(collect, err) - assert.Len(collect, resp.GetWebKeys(), 2) - } else { - assert.Error(collect, err) - } + assert.NoError(collect, err) + assert.Len(collect, resp.GetWebKeys(), 2) + }, retryDuration, tick) return instance, iamCTX, creationDate } -func assertFeatureDisabledError(t *testing.T, err error) { - t.Helper() - require.Error(t, err) - s := status.Convert(err) - assert.Equal(t, codes.FailedPrecondition, s.Code()) - assert.Contains(t, s.Message(), "WEBKEY-Ohx6E") -} - func checkWebKeyListState(ctx context.Context, t *testing.T, instance *integration.Instance, nKeys int, expectActiveKeyID string, config any, creationDate *timestamppb.Timestamp) { t.Helper() diff --git a/internal/api/grpc/webkey/v2beta/webkey.go b/internal/api/grpc/webkey/v2beta/webkey.go index d45288dff2..469d6fc9a6 100644 --- a/internal/api/grpc/webkey/v2beta/webkey.go +++ b/internal/api/grpc/webkey/v2beta/webkey.go @@ -5,9 +5,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/zerrors" webkey "github.com/zitadel/zitadel/pkg/grpc/webkey/v2beta" ) @@ -15,9 +13,6 @@ func (s *Server) CreateWebKey(ctx context.Context, req *webkey.CreateWebKeyReque ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if err = checkWebKeyFeature(ctx); err != nil { - return nil, err - } webKey, err := s.command.CreateWebKey(ctx, createWebKeyRequestToConfig(req)) if err != nil { return nil, err @@ -33,9 +28,6 @@ func (s *Server) ActivateWebKey(ctx context.Context, req *webkey.ActivateWebKeyR ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if err = checkWebKeyFeature(ctx); err != nil { - return nil, err - } details, err := s.command.ActivateWebKey(ctx, req.GetId()) if err != nil { return nil, err @@ -50,9 +42,6 @@ func (s *Server) DeleteWebKey(ctx context.Context, req *webkey.DeleteWebKeyReque ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if err = checkWebKeyFeature(ctx); err != nil { - return nil, err - } deletedAt, err := s.command.DeleteWebKey(ctx, req.GetId()) if err != nil { return nil, err @@ -71,9 +60,6 @@ func (s *Server) ListWebKeys(ctx context.Context, _ *webkey.ListWebKeysRequest) ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if err = checkWebKeyFeature(ctx); err != nil { - return nil, err - } list, err := s.query.ListWebKeys(ctx) if err != nil { return nil, err @@ -83,10 +69,3 @@ func (s *Server) ListWebKeys(ctx context.Context, _ *webkey.ListWebKeysRequest) WebKeys: webKeyDetailsListToPb(list), }, nil } - -func checkWebKeyFeature(ctx context.Context) error { - if !authz.GetFeatures(ctx).WebKey { - return zerrors.ThrowPreconditionFailed(nil, "WEBKEY-Ohx6E", "Errors.WebKey.FeatureDisabled") - } - return nil -} diff --git a/internal/api/oidc/access_token.go b/internal/api/oidc/access_token.go index 2f2880efc2..5c0b9c9f66 100644 --- a/internal/api/oidc/access_token.go +++ b/internal/api/oidc/access_token.go @@ -53,7 +53,7 @@ func (s *Server) verifyAccessToken(ctx context.Context, tkn string) (_ *accessTo tokenID, subject = split[0], split[1] } else { verifier := op.NewAccessTokenVerifier(op.IssuerFromContext(ctx), s.accessTokenKeySet, - op.WithSupportedAccessTokenSigningAlgorithms(supportedSigningAlgs(ctx)...), + op.WithSupportedAccessTokenSigningAlgorithms(supportedSigningAlgs()...), ) claims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](ctx, tkn, verifier) if err != nil { diff --git a/internal/api/oidc/auth_request_converter.go b/internal/api/oidc/auth_request_converter.go index 2144ca8ba1..064af20de0 100644 --- a/internal/api/oidc/auth_request_converter.go +++ b/internal/api/oidc/auth_request_converter.go @@ -140,13 +140,8 @@ func HttpHeadersFromContext(ctx context.Context) (userAgent, acceptLang string) if !ok { return } - if agents, ok := ctxHeaders[http_utils.UserAgentHeader]; ok { - userAgent = agents[0] - } - if langs, ok := ctxHeaders[http_utils.AcceptLanguage]; ok { - acceptLang = langs[0] - } - return userAgent, acceptLang + return ctxHeaders.Get(http_utils.UserAgentHeader), + ctxHeaders.Get(http_utils.AcceptLanguage) } func IpFromContext(ctx context.Context) net.IP { diff --git a/internal/api/oidc/integration_test/keys_test.go b/internal/api/oidc/integration_test/keys_test.go index 8b66e980d0..a6223cf1ee 100644 --- a/internal/api/oidc/integration_test/keys_test.go +++ b/internal/api/oidc/integration_test/keys_test.go @@ -14,12 +14,10 @@ import ( "github.com/stretchr/testify/require" "github.com/zitadel/oidc/v3/pkg/client" "github.com/zitadel/oidc/v3/pkg/oidc" - "google.golang.org/protobuf/proto" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/integration" - "github.com/zitadel/zitadel/pkg/grpc/feature/v2" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) @@ -53,25 +51,16 @@ func TestServer_Keys(t *testing.T) { require.NoError(t, err) tests := []struct { - name string - webKeyFeature bool - wantLen int + name string + wantLen int }{ { - name: "legacy only", - webKeyFeature: false, - wantLen: 1, - }, - { - name: "webkeys with legacy", - webKeyFeature: true, - wantLen: 3, // 1 legacy + 2 created by enabling feature flag + name: "webkeys", + wantLen: 2, // 2 from instance creation. }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ensureWebKeyFeature(t, instance, tt.webKeyFeature) - assert.EventuallyWithT(t, func(ttt *assert.CollectT) { resp, err := http.Get(discovery.JwksURI) require.NoError(ttt, err) @@ -92,30 +81,10 @@ func TestServer_Keys(t *testing.T) { } cacheControl := resp.Header.Get("cache-control") - if tt.webKeyFeature { - require.Equal(ttt, "max-age=300, must-revalidate", cacheControl) - return - } - require.Equal(ttt, "no-store", cacheControl) + require.Equal(ttt, "max-age=300, must-revalidate", cacheControl) }, time.Minute, time.Second/10) }) } } - -func ensureWebKeyFeature(t *testing.T, instance *integration.Instance, set bool) { - ctxIam := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - - _, err := instance.Client.FeatureV2.SetInstanceFeatures(ctxIam, &feature.SetInstanceFeaturesRequest{ - WebKey: proto.Bool(set), - }) - require.NoError(t, err) - - t.Cleanup(func() { - _, err := instance.Client.FeatureV2.SetInstanceFeatures(ctxIam, &feature.SetInstanceFeaturesRequest{ - WebKey: proto.Bool(false), - }) - require.NoError(t, err) - }) -} diff --git a/internal/api/oidc/integration_test/userinfo_test.go b/internal/api/oidc/integration_test/userinfo_test.go index bf201b242e..b3bc836343 100644 --- a/internal/api/oidc/integration_test/userinfo_test.go +++ b/internal/api/oidc/integration_test/userinfo_test.go @@ -35,21 +35,14 @@ func TestServer_UserInfo(t *testing.T) { tests := []struct { name string trigger bool - webKey bool }{ { name: "trigger enabled", trigger: true, }, - - // This is the only functional test we need to cover web keys. - // - By creating tokens the signer is tested - // - When obtaining the tokens, the RP verifies the ID Token using the key set from the jwks endpoint. - // - By calling userinfo with the access token as JWT, the Token Verifier with the public key cache is tested. { - name: "web keys", + name: "trigger disabled", trigger: false, - webKey: true, }, } @@ -57,7 +50,6 @@ func TestServer_UserInfo(t *testing.T) { t.Run(tt.name, func(t *testing.T) { _, err := Instance.Client.FeatureV2.SetInstanceFeatures(iamOwnerCTX, &feature.SetInstanceFeaturesRequest{ OidcTriggerIntrospectionProjections: &tt.trigger, - WebKey: &tt.webKey, }) require.NoError(t, err) testServer_UserInfo(t) diff --git a/internal/api/oidc/key.go b/internal/api/oidc/key.go index 852bbc7db8..61f874664f 100644 --- a/internal/api/oidc/key.go +++ b/internal/api/oidc/key.go @@ -10,18 +10,12 @@ import ( "github.com/go-jose/go-jose/v4" "github.com/jonboulle/clockwork" - "github.com/muhlemmer/gu" - "github.com/shopspring/decimal" - "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/op" "github.com/zitadel/zitadel/internal/api/authz" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/query" - "github.com/zitadel/zitadel/internal/repository/instance" - "github.com/zitadel/zitadel/internal/repository/keypair" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -36,11 +30,8 @@ var supportedWebKeyAlgs = []string{ string(jose.ES512), } -func supportedSigningAlgs(ctx context.Context) []string { - if authz.GetFeatures(ctx).WebKey { - return supportedWebKeyAlgs - } - return []string{string(jose.RS256)} +func supportedSigningAlgs() []string { + return supportedWebKeyAlgs } type cachedPublicKey struct { @@ -211,15 +202,6 @@ func withKeyExpiryCheck(check bool) keySetOption { } } -func jsonWebkey(key query.PublicKey) *jose.JSONWebKey { - return &jose.JSONWebKey{ - KeyID: key.ID(), - Algorithm: key.Algorithm(), - Use: key.Use().String(), - Key: key.Key(), - } -} - // keySetMap is a mapping of key IDs to public key data. type keySetMap map[string][]byte @@ -250,7 +232,6 @@ func (k keySetMap) VerifySignature(ctx context.Context, jws *jose.JSONWebSignatu } const ( - locksTable = "projections.locks" signingKey = "signing_key" oidcUser = "OIDC" @@ -279,203 +260,36 @@ func (s *SigningKey) ID() string { return s.id } -// PublicKey wraps the query.PublicKey to implement the op.Key interface -type PublicKey struct { - key query.PublicKey -} - -func (s *PublicKey) Algorithm() jose.SignatureAlgorithm { - return jose.SignatureAlgorithm(s.key.Algorithm()) -} - -func (s *PublicKey) Use() string { - return s.key.Use().String() -} - -func (s *PublicKey) Key() interface{} { - return s.key.Key() -} - -func (s *PublicKey) ID() string { - return s.key.ID() -} - // KeySet implements the op.Storage interface func (o *OPStorage) KeySet(ctx context.Context) (keys []op.Key, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - err = retry(func() error { - publicKeys, err := o.query.ActivePublicKeys(ctx, time.Now()) - if err != nil { - return err - } - keys = make([]op.Key, len(publicKeys.Keys)) - for i, key := range publicKeys.Keys { - keys[i] = &PublicKey{key} - } - return nil - }) - return keys, err + panic(o.panicErr("KeySet")) } // SignatureAlgorithms implements the op.Storage interface func (o *OPStorage) SignatureAlgorithms(ctx context.Context) ([]jose.SignatureAlgorithm, error) { - key, err := o.SigningKey(ctx) - if err != nil { - logging.WithError(err).Warn("unable to fetch signing key") - return nil, err - } - return []jose.SignatureAlgorithm{key.SignatureAlgorithm()}, nil + panic(o.panicErr("SignatureAlgorithms")) } // SigningKey implements the op.Storage interface func (o *OPStorage) SigningKey(ctx context.Context) (key op.SigningKey, err error) { - err = retry(func() error { - key, err = o.getSigningKey(ctx) - if err != nil { - return err - } - if key == nil { - return zerrors.ThrowNotFound(nil, "OIDC-ve4Qu", "Errors.Internal") - } - return nil - }) - return key, err -} - -func (o *OPStorage) getSigningKey(ctx context.Context) (op.SigningKey, error) { - keys, err := o.query.ActivePrivateSigningKey(ctx, time.Now().Add(gracefulPeriod)) - if err != nil { - return nil, err - } - if len(keys.Keys) > 0 { - return PrivateKeyToSigningKey(SelectSigningKey(keys.Keys), o.encAlg) - } - var position decimal.Decimal - if keys.State != nil { - position = keys.State.Position - } - return nil, o.refreshSigningKey(ctx, position) -} - -func (o *OPStorage) refreshSigningKey(ctx context.Context, position decimal.Decimal) error { - ok, err := o.ensureIsLatestKey(ctx, position) - if err != nil || !ok { - return zerrors.ThrowInternal(err, "OIDC-ASfh3", "cannot ensure that projection is up to date") - } - err = o.lockAndGenerateSigningKeyPair(ctx) - if err != nil { - return zerrors.ThrowInternal(err, "OIDC-ADh31", "could not create signing key") - } - return zerrors.ThrowInternal(nil, "OIDC-Df1bh", "") -} - -func (o *OPStorage) ensureIsLatestKey(ctx context.Context, position decimal.Decimal) (bool, error) { - maxSequence, err := o.getMaxKeyPosition(ctx) - if err != nil { - return false, fmt.Errorf("error retrieving new events: %w", err) - } - return position.GreaterThanOrEqual(maxSequence), nil -} - -func PrivateKeyToSigningKey(key query.PrivateKey, algorithm crypto.EncryptionAlgorithm) (_ op.SigningKey, err error) { - keyData, err := crypto.Decrypt(key.Key(), algorithm) - if err != nil { - return nil, err - } - privateKey, err := crypto.BytesToPrivateKey(keyData) - if err != nil { - return nil, err - } - return &SigningKey{ - algorithm: jose.SignatureAlgorithm(key.Algorithm()), - key: privateKey, - id: key.ID(), - }, nil -} - -func (o *OPStorage) lockAndGenerateSigningKeyPair(ctx context.Context) error { - logging.Info("lock and generate signing key pair") - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - errs := o.locker.Lock(ctx, lockDuration, authz.GetInstance(ctx).InstanceID()) - err, ok := <-errs - if err != nil || !ok { - if zerrors.IsErrorAlreadyExists(err) { - return nil - } - logging.OnError(err).Debug("initial lock failed") - return err - } - - return o.command.GenerateSigningKeyPair(setOIDCCtx(ctx), "RS256") -} - -func (o *OPStorage) getMaxKeyPosition(ctx context.Context) (decimal.Decimal, error) { - return o.eventstore.LatestPosition(ctx, - eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition). - ResourceOwner(authz.GetInstance(ctx).InstanceID()). - AwaitOpenTransactions(). - AddQuery(). - AggregateTypes( - keypair.AggregateType, - instance.AggregateType, - ). - EventTypes( - keypair.AddedEventType, - instance.InstanceRemovedEventType, - ). - Builder(), - ) -} - -func SelectSigningKey(keys []query.PrivateKey) query.PrivateKey { - return keys[len(keys)-1] -} - -func setOIDCCtx(ctx context.Context) context.Context { - return authz.SetCtxData(ctx, authz.CtxData{UserID: oidcUser, OrgID: authz.GetInstance(ctx).InstanceID()}) -} - -func retry(retryable func() error) (err error) { - for i := 0; i < retryCount; i++ { - err = retryable() - if err == nil { - return nil - } - time.Sleep(retryBackoff) - } - return err + panic(o.panicErr("SigningKey")) } func (s *Server) Keys(ctx context.Context, r *op.Request[struct{}]) (_ *op.Response, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if !authz.GetFeatures(ctx).WebKey { - return s.LegacyServer.Keys(ctx, r) - } - keyset, err := s.query.GetWebKeySet(ctx) if err != nil { return nil, err } - // Return legacy keys, so we do not invalidate all tokens - // once the feature flag is enabled. - legacyKeys, err := s.query.ActivePublicKeys(ctx, time.Now()) - logging.OnError(err).Error("oidc server: active public keys (legacy)") - appendPublicKeysToWebKeySet(keyset, legacyKeys) - resp := op.NewResponse(keyset) if s.jwksCacheControlMaxAge != 0 { resp.Header.Set(http_util.CacheControl, fmt.Sprintf("max-age=%d, must-revalidate", int(s.jwksCacheControlMaxAge/time.Second)), ) } - return resp, nil } @@ -497,20 +311,10 @@ func appendPublicKeysToWebKeySet(keyset *jose.JSONWebKeySet, pubkeys *query.Publ func queryKeyFunc(q *query.Queries) func(ctx context.Context, keyID string) (*jose.JSONWebKey, *time.Time, error) { return func(ctx context.Context, keyID string) (*jose.JSONWebKey, *time.Time, error) { - if authz.GetFeatures(ctx).WebKey { - webKey, err := q.GetPublicWebKeyByID(ctx, keyID) - if err == nil { - return webKey, nil, nil - } - if !zerrors.IsNotFound(err) { - return nil, nil, err - } - } - - pubKey, err := q.GetPublicKeyByID(ctx, keyID) + webKey, err := q.GetPublicWebKeyByID(ctx, keyID) if err != nil { return nil, nil, err } - return jsonWebkey(pubKey), gu.Ptr(pubKey.Expiry()), nil + return webKey, nil, nil } } diff --git a/internal/api/oidc/op.go b/internal/api/oidc/op.go index d7171b957b..6f59ce3525 100644 --- a/internal/api/oidc/op.go +++ b/internal/api/oidc/op.go @@ -18,10 +18,8 @@ import ( "github.com/zitadel/zitadel/internal/cache" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/domain/federatedlogout" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/handler/crdb" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/telemetry/metrics" "github.com/zitadel/zitadel/internal/zerrors" @@ -75,7 +73,6 @@ type OPStorage struct { defaultRefreshTokenIdleExpiration time.Duration defaultRefreshTokenExpiration time.Duration encAlg crypto.EncryptionAlgorithm - locker crdb.Locker assetAPIPrefix func(ctx context.Context) string contextToIssuer func(context.Context) string federateLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout] @@ -91,14 +88,14 @@ type Provider struct { // IDTokenHintVerifier configures a Verifier and supported signing algorithms based on the Web Key feature in the context. func (o *Provider) IDTokenHintVerifier(ctx context.Context) *op.IDTokenHintVerifier { return op.NewIDTokenHintVerifier(op.IssuerFromContext(ctx), o.idTokenHintKeySet, op.WithSupportedIDTokenHintSigningAlgorithms( - supportedSigningAlgs(ctx)..., + supportedSigningAlgs()..., )) } // AccessTokenVerifier configures a Verifier and supported signing algorithms based on the Web Key feature in the context. func (o *Provider) AccessTokenVerifier(ctx context.Context) *op.AccessTokenVerifier { return op.NewAccessTokenVerifier(op.IssuerFromContext(ctx), o.accessTokenKeySet, op.WithSupportedAccessTokenSigningAlgorithms( - supportedSigningAlgs(ctx)..., + supportedSigningAlgs()..., )) } @@ -113,7 +110,6 @@ func NewServer( encryptionAlg crypto.EncryptionAlgorithm, cryptoKey []byte, es *eventstore.Eventstore, - projections *database.DB, userAgentCookie, instanceHandler func(http.Handler) http.Handler, accessHandler *middleware.AccessInterceptor, fallbackLogger *slog.Logger, @@ -124,7 +120,7 @@ func NewServer( if err != nil { return nil, zerrors.ThrowInternal(err, "OIDC-EGrqd", "cannot create op config: %w") } - storage := newStorage(config, command, query, repo, encryptionAlg, es, projections, ContextToIssuer, federatedLogoutCache) + storage := newStorage(config, command, query, repo, encryptionAlg, es, ContextToIssuer, federatedLogoutCache) keyCache := newPublicKeyCache(ctx, config.PublicKeyCacheMaxAge, queryKeyFunc(query)) accessTokenKeySet := newOidcKeySet(keyCache, withKeyExpiryCheck(true)) idTokenHintKeySet := newOidcKeySet(keyCache) @@ -236,7 +232,6 @@ func newStorage( repo repository.Repository, encAlg crypto.EncryptionAlgorithm, es *eventstore.Eventstore, - db *database.DB, contextToIssuer func(context.Context) string, federateLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout], ) *OPStorage { @@ -253,7 +248,6 @@ func newStorage( defaultRefreshTokenIdleExpiration: config.DefaultRefreshTokenIdleExpiration, defaultRefreshTokenExpiration: config.DefaultRefreshTokenExpiration, encAlg: encAlg, - locker: crdb.NewLocker(db.DB, locksTable, signingKey), assetAPIPrefix: assets.AssetAPI(), contextToIssuer: contextToIssuer, federateLogoutCache: federateLogoutCache, diff --git a/internal/api/oidc/server.go b/internal/api/oidc/server.go index 1a0854e2a6..df7127443f 100644 --- a/internal/api/oidc/server.go +++ b/internal/api/oidc/server.go @@ -188,7 +188,7 @@ func (s *Server) createDiscoveryConfig(ctx context.Context, supportedUILocales o }, GrantTypesSupported: op.GrantTypes(s.Provider()), SubjectTypesSupported: op.SubjectTypes(s.Provider()), - IDTokenSigningAlgValuesSupported: supportedSigningAlgs(ctx), + IDTokenSigningAlgValuesSupported: supportedSigningAlgs(), RequestObjectSigningAlgValuesSupported: op.RequestObjectSigAlgorithms(s.Provider()), TokenEndpointAuthMethodsSupported: op.AuthMethodsTokenEndpoint(s.Provider()), TokenEndpointAuthSigningAlgValuesSupported: op.TokenSigAlgorithms(s.Provider()), diff --git a/internal/api/oidc/server_test.go b/internal/api/oidc/server_test.go index 76d073151a..9bf22fd210 100644 --- a/internal/api/oidc/server_test.go +++ b/internal/api/oidc/server_test.go @@ -8,9 +8,6 @@ import ( "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" "golang.org/x/text/language" - - "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/feature" ) func TestServer_createDiscoveryConfig(t *testing.T) { @@ -63,92 +60,6 @@ func TestServer_createDiscoveryConfig(t *testing.T) { ctx: op.ContextWithIssuer(context.Background(), "https://issuer.com"), supportedUILocales: []language.Tag{language.English, language.German}, }, - &oidc.DiscoveryConfiguration{ - Issuer: "https://issuer.com", - AuthorizationEndpoint: "https://issuer.com/auth", - TokenEndpoint: "https://issuer.com/token", - IntrospectionEndpoint: "https://issuer.com/introspect", - UserinfoEndpoint: "https://issuer.com/userinfo", - RevocationEndpoint: "https://issuer.com/revoke", - EndSessionEndpoint: "https://issuer.com/logout", - DeviceAuthorizationEndpoint: "https://issuer.com/device", - CheckSessionIframe: "", - JwksURI: "https://issuer.com/keys", - RegistrationEndpoint: "", - ScopesSupported: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopePhone, oidc.ScopeAddress, oidc.ScopeOfflineAccess}, - ResponseTypesSupported: []string{string(oidc.ResponseTypeCode), string(oidc.ResponseTypeIDTokenOnly), string(oidc.ResponseTypeIDToken)}, - ResponseModesSupported: []string{string(oidc.ResponseModeQuery), string(oidc.ResponseModeFragment), string(oidc.ResponseModeFormPost)}, - GrantTypesSupported: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeImplicit, oidc.GrantTypeRefreshToken, oidc.GrantTypeBearer}, - ACRValuesSupported: nil, - SubjectTypesSupported: []string{"public"}, - IDTokenSigningAlgValuesSupported: []string{"RS256"}, - IDTokenEncryptionAlgValuesSupported: nil, - IDTokenEncryptionEncValuesSupported: nil, - UserinfoSigningAlgValuesSupported: nil, - UserinfoEncryptionAlgValuesSupported: nil, - UserinfoEncryptionEncValuesSupported: nil, - RequestObjectSigningAlgValuesSupported: []string{"RS256"}, - RequestObjectEncryptionAlgValuesSupported: nil, - RequestObjectEncryptionEncValuesSupported: nil, - TokenEndpointAuthMethodsSupported: []oidc.AuthMethod{oidc.AuthMethodNone, oidc.AuthMethodBasic, oidc.AuthMethodPost, oidc.AuthMethodPrivateKeyJWT}, - TokenEndpointAuthSigningAlgValuesSupported: []string{"RS256"}, - RevocationEndpointAuthMethodsSupported: []oidc.AuthMethod{oidc.AuthMethodNone, oidc.AuthMethodBasic, oidc.AuthMethodPost, oidc.AuthMethodPrivateKeyJWT}, - RevocationEndpointAuthSigningAlgValuesSupported: []string{"RS256"}, - IntrospectionEndpointAuthMethodsSupported: []oidc.AuthMethod{oidc.AuthMethodBasic, oidc.AuthMethodPrivateKeyJWT}, - IntrospectionEndpointAuthSigningAlgValuesSupported: []string{"RS256"}, - DisplayValuesSupported: nil, - ClaimTypesSupported: nil, - ClaimsSupported: []string{"sub", "aud", "exp", "iat", "iss", "auth_time", "nonce", "acr", "amr", "c_hash", "at_hash", "act", "scopes", "client_id", "azp", "preferred_username", "name", "family_name", "given_name", "locale", "email", "email_verified", "phone_number", "phone_number_verified"}, - ClaimsParameterSupported: false, - CodeChallengeMethodsSupported: []oidc.CodeChallengeMethod{"S256"}, - ServiceDocumentation: "", - ClaimsLocalesSupported: nil, - UILocalesSupported: []language.Tag{language.English, language.German}, - RequestParameterSupported: true, - RequestURIParameterSupported: false, - RequireRequestURIRegistration: false, - OPPolicyURI: "", - OPTermsOfServiceURI: "", - }, - }, - { - "web keys feature enabled", - fields{ - LegacyServer: op.NewLegacyServer( - func() *op.Provider { - //nolint:staticcheck - provider, _ := op.NewForwardedOpenIDProvider("path", - &op.Config{ - CodeMethodS256: true, - AuthMethodPost: true, - AuthMethodPrivateKeyJWT: true, - GrantTypeRefreshToken: true, - RequestObjectSupported: true, - }, - nil, - ) - return provider - }(), - op.Endpoints{ - Authorization: op.NewEndpoint("auth"), - Token: op.NewEndpoint("token"), - Introspection: op.NewEndpoint("introspect"), - Userinfo: op.NewEndpoint("userinfo"), - Revocation: op.NewEndpoint("revoke"), - EndSession: op.NewEndpoint("logout"), - JwksURI: op.NewEndpoint("keys"), - DeviceAuthorization: op.NewEndpoint("device"), - }, - ), - signingKeyAlgorithm: "RS256", - }, - args{ - ctx: authz.WithFeatures( - op.ContextWithIssuer(context.Background(), "https://issuer.com"), - feature.Features{WebKey: true}, - ), - supportedUILocales: []language.Tag{language.English, language.German}, - }, &oidc.DiscoveryConfiguration{ Issuer: "https://issuer.com", AuthorizationEndpoint: "https://issuer.com/auth", diff --git a/internal/api/oidc/token.go b/internal/api/oidc/token.go index 485f455784..2efc0fb583 100644 --- a/internal/api/oidc/token.go +++ b/internal/api/oidc/token.go @@ -12,7 +12,6 @@ import ( "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -64,14 +63,13 @@ func (s *Server) accessTokenResponseFromSession(ctx context.Context, client op.C type SignerFunc func(ctx context.Context) (jose.Signer, jose.SignatureAlgorithm, error) func (s *Server) getSignerOnce() SignerFunc { - return GetSignerOnce(s.query.GetActiveSigningWebKey, s.Provider().Storage().SigningKey) + return GetSignerOnce(s.query.GetActiveSigningWebKey) } // GetSignerOnce returns a function which retrieves the instance's signer from the database once. // Repeated calls of the returned function return the same results. func GetSignerOnce( getActiveSigningWebKey func(ctx context.Context) (*jose.JSONWebKey, error), - getSigningKey func(ctx context.Context) (op.SigningKey, error), ) SignerFunc { var ( once sync.Once @@ -84,23 +82,12 @@ func GetSignerOnce( ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if authz.GetFeatures(ctx).WebKey { - var webKey *jose.JSONWebKey - webKey, err = getActiveSigningWebKey(ctx) - if err != nil { - return - } - signer, signAlg, err = signerFromWebKey(webKey) - return - } - - var signingKey op.SigningKey - signingKey, err = getSigningKey(ctx) + var webKey *jose.JSONWebKey + webKey, err = getActiveSigningWebKey(ctx) if err != nil { return } - signAlg = signingKey.SignatureAlgorithm() - signer, err = op.SignerFromKey(signingKey) + signer, signAlg, err = signerFromWebKey(webKey) }) return signer, signAlg, err } diff --git a/internal/api/saml/certificate.go b/internal/api/saml/certificate.go index 14752cd5cd..e0eb31255e 100644 --- a/internal/api/saml/certificate.go +++ b/internal/api/saml/certificate.go @@ -14,13 +14,14 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/keypair" "github.com/zitadel/zitadel/internal/zerrors" ) const ( - locksTable = "projections.locks" + locksTable = projection.LocksTable signingKey = "signing_key" samlUser = "SAML" diff --git a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go index b707631c22..d6c14afea3 100644 --- a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go +++ b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go @@ -4,7 +4,6 @@ import ( "context" "encoding/base64" "fmt" - "slices" "strings" "time" @@ -329,18 +328,10 @@ type openIDKeySet struct { // VerifySignature implements the oidc.KeySet interface // providing an implementation for the keys retrieved directly from Queries func (o *openIDKeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSignature) (payload []byte, err error) { - keySet := new(jose.JSONWebKeySet) - if authz.GetFeatures(ctx).WebKey { - keySet, err = o.Queries.GetWebKeySet(ctx) - if err != nil { - return nil, err - } - } - legacyKeySet, err := o.Queries.ActivePublicKeys(ctx, time.Now()) + keySet, err := o.Queries.GetWebKeySet(ctx) if err != nil { - return nil, fmt.Errorf("error fetching keys: %w", err) + return nil, err } - appendPublicKeysToWebKeySet(keySet, legacyKeySet) keyID, alg := oidc.GetKeyIDAndAlg(jws) key, err := oidc.FindMatchingKey(keyID, oidc.KeyUseSignature, alg, keySet.Keys...) if err != nil { @@ -348,19 +339,3 @@ func (o *openIDKeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSig } return jws.Verify(&key) } - -func appendPublicKeysToWebKeySet(keyset *jose.JSONWebKeySet, pubkeys *query.PublicKeys) { - if pubkeys == nil || len(pubkeys.Keys) == 0 { - return - } - keyset.Keys = slices.Grow(keyset.Keys, len(pubkeys.Keys)) - - for _, key := range pubkeys.Keys { - keyset.Keys = append(keyset.Keys, jose.JSONWebKey{ - Key: key.Key(), - KeyID: key.ID(), - Algorithm: key.Algorithm(), - Use: key.Use().String(), - }) - } -} diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go index 4d35d5a318..21de5653a9 100644 --- a/internal/command/instance_features.go +++ b/internal/command/instance_features.go @@ -3,11 +3,8 @@ package command import ( "context" - "github.com/muhlemmer/gu" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/feature" @@ -21,7 +18,6 @@ type InstanceFeatures struct { UserSchema *bool TokenExchange *bool ImprovedPerformance []feature.ImprovedPerformanceType - WebKey *bool DebugOIDCParentError *bool OIDCSingleV1SessionTermination *bool DisableUserTokenEvent *bool @@ -38,7 +34,6 @@ func (m *InstanceFeatures) isEmpty() bool { m.TokenExchange == nil && // nil check to allow unset improvements m.ImprovedPerformance == nil && - m.WebKey == nil && m.DebugOIDCParentError == nil && m.OIDCSingleV1SessionTermination == nil && m.DisableUserTokenEvent == nil && @@ -55,9 +50,6 @@ func (c *Commands) SetInstanceFeatures(ctx context.Context, f *InstanceFeatures) if err := c.eventstore.FilterToQueryReducer(ctx, wm); err != nil { return nil, err } - if err := c.setupWebKeyFeature(ctx, wm, f); err != nil { - return nil, err - } commands := wm.setCommands(ctx, f) if len(commands) == 0 { return writeModelToObjectDetails(wm.WriteModel), nil @@ -78,21 +70,6 @@ func prepareSetFeatures(instanceID string, f *InstanceFeatures) preparation.Vali } } -// setupWebKeyFeature generates the initial web keys for the instance, -// if the feature is enabled in the request and the feature wasn't enabled already in the writeModel. -// [Commands.GenerateInitialWebKeys] checks if keys already exist and does nothing if that's the case. -// The default config of a RSA key with 2048 and the SHA256 hasher is assumed. -// Users can customize this after using the webkey/v3 API. -func (c *Commands) setupWebKeyFeature(ctx context.Context, wm *InstanceFeaturesWriteModel, f *InstanceFeatures) error { - if !gu.Value(f.WebKey) || gu.Value(wm.WebKey) { - return nil - } - return c.GenerateInitialWebKeys(ctx, &crypto.WebKeyRSAConfig{ - Bits: crypto.RSABits2048, - Hasher: crypto.RSAHasherSHA256, - }) -} - func (c *Commands) ResetInstanceFeatures(ctx context.Context) (*domain.ObjectDetails, error) { instanceID := authz.GetInstance(ctx).InstanceID() wm := NewInstanceFeaturesWriteModel(instanceID) diff --git a/internal/command/instance_features_model.go b/internal/command/instance_features_model.go index 399013aded..8ca2865eae 100644 --- a/internal/command/instance_features_model.go +++ b/internal/command/instance_features_model.go @@ -71,7 +71,6 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, feature_v2.InstanceImprovedPerformanceEventType, - feature_v2.InstanceWebKeyEventType, feature_v2.InstanceDebugOIDCParentErrorEventType, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType, feature_v2.InstanceDisableUserTokenEvent, @@ -106,9 +105,6 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an case feature.KeyImprovedPerformance: v := value.([]feature.ImprovedPerformanceType) features.ImprovedPerformance = v - case feature.KeyWebKey: - v := value.(bool) - features.WebKey = &v case feature.KeyDebugOIDCParentError: v := value.(bool) features.DebugOIDCParentError = &v @@ -140,7 +136,6 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.InstanceTokenExchangeEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.InstanceUserSchemaEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.InstanceImprovedPerformanceEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.WebKey, f.WebKey, feature_v2.InstanceWebKeyEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DebugOIDCParentError, f.DebugOIDCParentError, feature_v2.InstanceDebugOIDCParentErrorEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.OIDCSingleV1SessionTermination, f.OIDCSingleV1SessionTermination, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DisableUserTokenEvent, f.DisableUserTokenEvent, feature_v2.InstanceDisableUserTokenEvent) diff --git a/internal/command/key_pair.go b/internal/command/key_pair.go index 90eaf7e3da..76193431d6 100644 --- a/internal/command/key_pair.go +++ b/internal/command/key_pair.go @@ -13,31 +13,6 @@ import ( "github.com/zitadel/zitadel/internal/repository/keypair" ) -func (c *Commands) GenerateSigningKeyPair(ctx context.Context, algorithm string) error { - privateCrypto, publicCrypto, err := crypto.GenerateEncryptedKeyPair(c.keySize, c.keyAlgorithm) - if err != nil { - return err - } - keyID, err := c.idGenerator.Next() - if err != nil { - return err - } - - privateKeyExp := time.Now().UTC().Add(c.privateKeyLifetime) - publicKeyExp := time.Now().UTC().Add(c.publicKeyLifetime) - - keyPairWriteModel := NewKeyPairWriteModel(keyID, authz.GetInstance(ctx).InstanceID()) - keyAgg := KeyPairAggregateFromWriteModel(&keyPairWriteModel.WriteModel) - _, err = c.eventstore.Push(ctx, keypair.NewAddedEvent( - ctx, - keyAgg, - crypto.KeyUsageSigning, - algorithm, - privateCrypto, publicCrypto, - privateKeyExp, publicKeyExp)) - return err -} - func (c *Commands) GenerateSAMLCACertificate(ctx context.Context, algorithm string) error { now := time.Now().UTC() after := now.Add(c.certificateLifetime) diff --git a/internal/crypto/rsa.go b/internal/crypto/rsa.go index 198610d8aa..3fd9a77569 100644 --- a/internal/crypto/rsa.go +++ b/internal/crypto/rsa.go @@ -21,14 +21,6 @@ func GenerateKeyPair(bits int) (*rsa.PrivateKey, *rsa.PublicKey, error) { return privkey, &privkey.PublicKey, nil } -func GenerateEncryptedKeyPair(bits int, alg EncryptionAlgorithm) (*CryptoValue, *CryptoValue, error) { - privateKey, publicKey, err := GenerateKeyPair(bits) - if err != nil { - return nil, nil, err - } - return EncryptKeys(privateKey, publicKey, alg) -} - type CertificateInformations struct { SerialNumber *big.Int Organisation []string diff --git a/internal/feature/feature.go b/internal/feature/feature.go index 2e32b6b122..107b06edf1 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -9,7 +9,7 @@ import ( type Key int const ( - // Reserved: 3, 6 + // Reserved: 3, 6, 8 KeyUnspecified Key = 0 KeyLoginDefaultOrg Key = 1 @@ -17,7 +17,6 @@ const ( KeyUserSchema Key = 4 KeyTokenExchange Key = 5 KeyImprovedPerformance Key = 7 - KeyWebKey Key = 8 KeyDebugOIDCParentError Key = 9 KeyOIDCSingleV1SessionTermination Key = 10 KeyDisableUserTokenEvent Key = 11 @@ -46,7 +45,6 @@ type Features struct { UserSchema bool `json:"user_schema,omitempty"` TokenExchange bool `json:"token_exchange,omitempty"` ImprovedPerformance []ImprovedPerformanceType `json:"improved_performance,omitempty"` - WebKey bool `json:"web_key,omitempty"` DebugOIDCParentError bool `json:"debug_oidc_parent_error,omitempty"` OIDCSingleV1SessionTermination bool `json:"oidc_single_v1_session_termination,omitempty"` DisableUserTokenEvent bool `json:"disable_user_token_event,omitempty"` diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index e06199120a..1b4fb9a3ad 100644 --- a/internal/feature/key_enumer.go +++ b/internal/feature/key_enumer.go @@ -12,14 +12,17 @@ const ( _KeyLowerName_0 = "unspecifiedlogin_default_orgtrigger_introspection_projections" _KeyName_1 = "user_schematoken_exchange" _KeyLowerName_1 = "user_schematoken_exchange" - _KeyName_2 = "improved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" - _KeyLowerName_2 = "improved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" + _KeyName_2 = "improved_performance" + _KeyLowerName_2 = "improved_performance" + _KeyName_3 = "debug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" + _KeyLowerName_3 = "debug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" ) var ( _KeyIndex_0 = [...]uint8{0, 11, 28, 61} _KeyIndex_1 = [...]uint8{0, 11, 25} - _KeyIndex_2 = [...]uint8{0, 20, 27, 50, 84, 108, 134, 142, 161, 184} + _KeyIndex_2 = [...]uint8{0, 20} + _KeyIndex_3 = [...]uint8{0, 23, 57, 81, 107, 115, 134, 157} ) func (i Key) String() string { @@ -29,9 +32,11 @@ func (i Key) String() string { case 4 <= i && i <= 5: i -= 4 return _KeyName_1[_KeyIndex_1[i]:_KeyIndex_1[i+1]] - case 7 <= i && i <= 15: - i -= 7 - return _KeyName_2[_KeyIndex_2[i]:_KeyIndex_2[i+1]] + case i == 7: + return _KeyName_2 + case 9 <= i && i <= 15: + i -= 9 + return _KeyName_3[_KeyIndex_3[i]:_KeyIndex_3[i+1]] default: return fmt.Sprintf("Key(%d)", i) } @@ -47,7 +52,6 @@ func _KeyNoOp() { _ = x[KeyUserSchema-(4)] _ = x[KeyTokenExchange-(5)] _ = x[KeyImprovedPerformance-(7)] - _ = x[KeyWebKey-(8)] _ = x[KeyDebugOIDCParentError-(9)] _ = x[KeyOIDCSingleV1SessionTermination-(10)] _ = x[KeyDisableUserTokenEvent-(11)] @@ -57,7 +61,7 @@ func _KeyNoOp() { _ = x[KeyConsoleUseV2UserApi-(15)] } -var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyUserSchema, KeyTokenExchange, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyUserSchema, KeyTokenExchange, KeyImprovedPerformance, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} var _KeyNameToValueMap = map[string]Key{ _KeyName_0[0:11]: KeyUnspecified, @@ -72,22 +76,20 @@ var _KeyNameToValueMap = map[string]Key{ _KeyLowerName_1[11:25]: KeyTokenExchange, _KeyName_2[0:20]: KeyImprovedPerformance, _KeyLowerName_2[0:20]: KeyImprovedPerformance, - _KeyName_2[20:27]: KeyWebKey, - _KeyLowerName_2[20:27]: KeyWebKey, - _KeyName_2[27:50]: KeyDebugOIDCParentError, - _KeyLowerName_2[27:50]: KeyDebugOIDCParentError, - _KeyName_2[50:84]: KeyOIDCSingleV1SessionTermination, - _KeyLowerName_2[50:84]: KeyOIDCSingleV1SessionTermination, - _KeyName_2[84:108]: KeyDisableUserTokenEvent, - _KeyLowerName_2[84:108]: KeyDisableUserTokenEvent, - _KeyName_2[108:134]: KeyEnableBackChannelLogout, - _KeyLowerName_2[108:134]: KeyEnableBackChannelLogout, - _KeyName_2[134:142]: KeyLoginV2, - _KeyLowerName_2[134:142]: KeyLoginV2, - _KeyName_2[142:161]: KeyPermissionCheckV2, - _KeyLowerName_2[142:161]: KeyPermissionCheckV2, - _KeyName_2[161:184]: KeyConsoleUseV2UserApi, - _KeyLowerName_2[161:184]: KeyConsoleUseV2UserApi, + _KeyName_3[0:23]: KeyDebugOIDCParentError, + _KeyLowerName_3[0:23]: KeyDebugOIDCParentError, + _KeyName_3[23:57]: KeyOIDCSingleV1SessionTermination, + _KeyLowerName_3[23:57]: KeyOIDCSingleV1SessionTermination, + _KeyName_3[57:81]: KeyDisableUserTokenEvent, + _KeyLowerName_3[57:81]: KeyDisableUserTokenEvent, + _KeyName_3[81:107]: KeyEnableBackChannelLogout, + _KeyLowerName_3[81:107]: KeyEnableBackChannelLogout, + _KeyName_3[107:115]: KeyLoginV2, + _KeyLowerName_3[107:115]: KeyLoginV2, + _KeyName_3[115:134]: KeyPermissionCheckV2, + _KeyLowerName_3[115:134]: KeyPermissionCheckV2, + _KeyName_3[134:157]: KeyConsoleUseV2UserApi, + _KeyLowerName_3[134:157]: KeyConsoleUseV2UserApi, } var _KeyNames = []string{ @@ -97,14 +99,13 @@ var _KeyNames = []string{ _KeyName_1[0:11], _KeyName_1[11:25], _KeyName_2[0:20], - _KeyName_2[20:27], - _KeyName_2[27:50], - _KeyName_2[50:84], - _KeyName_2[84:108], - _KeyName_2[108:134], - _KeyName_2[134:142], - _KeyName_2[142:161], - _KeyName_2[161:184], + _KeyName_3[0:23], + _KeyName_3[23:57], + _KeyName_3[57:81], + _KeyName_3[81:107], + _KeyName_3[107:115], + _KeyName_3[115:134], + _KeyName_3[134:157], } // KeyString retrieves an enum value from the enum constants string name. diff --git a/internal/notification/handlers/back_channel_logout.go b/internal/notification/handlers/back_channel_logout.go index f1a99146ca..983915ac28 100644 --- a/internal/notification/handlers/back_channel_logout.go +++ b/internal/notification/handlers/back_channel_logout.go @@ -7,10 +7,8 @@ import ( "sync" "time" - "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/crypto" "github.com/zitadel/oidc/v3/pkg/oidc" - "github.com/zitadel/oidc/v3/pkg/op" "github.com/zitadel/zitadel/internal/api/authz" http_utils "github.com/zitadel/zitadel/internal/api/http" @@ -149,7 +147,7 @@ func (u *backChannelLogoutNotifier) terminateSession(ctx context.Context, id str return err } - getSigner := zoidc.GetSignerOnce(u.queries.GetActiveSigningWebKey, u.signingKey) + getSigner := zoidc.GetSignerOnce(u.queries.GetActiveSigningWebKey) var wg sync.WaitGroup wg.Add(len(sessions.sessions)) @@ -172,20 +170,6 @@ func (u *backChannelLogoutNotifier) terminateSession(ctx context.Context, id str return errors.Join(errs...) } -func (u *backChannelLogoutNotifier) signingKey(ctx context.Context) (op.SigningKey, error) { - keys, err := u.queries.ActivePrivateSigningKey(ctx, time.Now()) - if err != nil { - return nil, err - } - if len(keys.Keys) == 0 { - logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID()). - Info("There's no active signing key and automatic rotation is not supported for back channel logout." + - "Please enable the webkey management feature on your instance") - return nil, zerrors.ThrowPreconditionFailed(nil, "HANDL-DF3nf", "no active signing key") - } - return zoidc.PrivateKeyToSigningKey(zoidc.SelectSigningKey(keys.Keys), u.keyEncryptionAlg) -} - func (u *backChannelLogoutNotifier) sendLogoutToken(ctx context.Context, oidcSession *backChannelLogoutOIDCSessions, e eventstore.Event, getSigner zoidc.SignerFunc) error { token, err := u.logoutToken(ctx, oidcSession, getSigner) if err != nil { diff --git a/internal/notification/handlers/mock/commands.mock.go b/internal/notification/handlers/mock/commands.mock.go index 7d41c30f30..ec327de8e8 100644 --- a/internal/notification/handlers/mock/commands.mock.go +++ b/internal/notification/handlers/mock/commands.mock.go @@ -23,6 +23,7 @@ import ( type MockCommands struct { ctrl *gomock.Controller recorder *MockCommandsMockRecorder + isgomock struct{} } // MockCommandsMockRecorder is the mock recorder for MockCommands. @@ -43,197 +44,197 @@ func (m *MockCommands) EXPECT() *MockCommandsMockRecorder { } // HumanEmailVerificationCodeSent mocks base method. -func (m *MockCommands) HumanEmailVerificationCodeSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) HumanEmailVerificationCodeSent(ctx context.Context, orgID, userID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanEmailVerificationCodeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "HumanEmailVerificationCodeSent", ctx, orgID, userID) ret0, _ := ret[0].(error) return ret0 } // HumanEmailVerificationCodeSent indicates an expected call of HumanEmailVerificationCodeSent. -func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(ctx, orgID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanEmailVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanEmailVerificationCodeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanEmailVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanEmailVerificationCodeSent), ctx, orgID, userID) } // HumanInitCodeSent mocks base method. -func (m *MockCommands) HumanInitCodeSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) HumanInitCodeSent(ctx context.Context, orgID, userID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanInitCodeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "HumanInitCodeSent", ctx, orgID, userID) ret0, _ := ret[0].(error) return ret0 } // HumanInitCodeSent indicates an expected call of HumanInitCodeSent. -func (mr *MockCommandsMockRecorder) HumanInitCodeSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanInitCodeSent(ctx, orgID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanInitCodeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanInitCodeSent), ctx, orgID, userID) } // HumanOTPEmailCodeSent mocks base method. -func (m *MockCommands) HumanOTPEmailCodeSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) HumanOTPEmailCodeSent(ctx context.Context, userID, resourceOwner string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanOTPEmailCodeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "HumanOTPEmailCodeSent", ctx, userID, resourceOwner) ret0, _ := ret[0].(error) return ret0 } // HumanOTPEmailCodeSent indicates an expected call of HumanOTPEmailCodeSent. -func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(ctx, userID, resourceOwner any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPEmailCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPEmailCodeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPEmailCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPEmailCodeSent), ctx, userID, resourceOwner) } // HumanOTPSMSCodeSent mocks base method. -func (m *MockCommands) HumanOTPSMSCodeSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { +func (m *MockCommands) HumanOTPSMSCodeSent(ctx context.Context, userID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanOTPSMSCodeSent", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "HumanOTPSMSCodeSent", ctx, userID, resourceOwner, generatorInfo) ret0, _ := ret[0].(error) return ret0 } // HumanOTPSMSCodeSent indicates an expected call of HumanOTPSMSCodeSent. -func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(ctx, userID, resourceOwner, generatorInfo any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPSMSCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPSMSCodeSent), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPSMSCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPSMSCodeSent), ctx, userID, resourceOwner, generatorInfo) } // HumanPasswordlessInitCodeSent mocks base method. -func (m *MockCommands) HumanPasswordlessInitCodeSent(arg0 context.Context, arg1, arg2, arg3 string) error { +func (m *MockCommands) HumanPasswordlessInitCodeSent(ctx context.Context, userID, resourceOwner, codeID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanPasswordlessInitCodeSent", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "HumanPasswordlessInitCodeSent", ctx, userID, resourceOwner, codeID) ret0, _ := ret[0].(error) return ret0 } // HumanPasswordlessInitCodeSent indicates an expected call of HumanPasswordlessInitCodeSent. -func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(ctx, userID, resourceOwner, codeID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPasswordlessInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPasswordlessInitCodeSent), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPasswordlessInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPasswordlessInitCodeSent), ctx, userID, resourceOwner, codeID) } // HumanPhoneVerificationCodeSent mocks base method. -func (m *MockCommands) HumanPhoneVerificationCodeSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { +func (m *MockCommands) HumanPhoneVerificationCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanPhoneVerificationCodeSent", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "HumanPhoneVerificationCodeSent", ctx, orgID, userID, generatorInfo) ret0, _ := ret[0].(error) return ret0 } // HumanPhoneVerificationCodeSent indicates an expected call of HumanPhoneVerificationCodeSent. -func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(ctx, orgID, userID, generatorInfo any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPhoneVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPhoneVerificationCodeSent), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPhoneVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPhoneVerificationCodeSent), ctx, orgID, userID, generatorInfo) } // InviteCodeSent mocks base method. -func (m *MockCommands) InviteCodeSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) InviteCodeSent(ctx context.Context, orgID, userID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InviteCodeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "InviteCodeSent", ctx, orgID, userID) ret0, _ := ret[0].(error) return ret0 } // InviteCodeSent indicates an expected call of InviteCodeSent. -func (mr *MockCommandsMockRecorder) InviteCodeSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) InviteCodeSent(ctx, orgID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteCodeSent", reflect.TypeOf((*MockCommands)(nil).InviteCodeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteCodeSent", reflect.TypeOf((*MockCommands)(nil).InviteCodeSent), ctx, orgID, userID) } // MilestonePushed mocks base method. -func (m *MockCommands) MilestonePushed(arg0 context.Context, arg1 string, arg2 milestone.Type, arg3 []string) error { +func (m *MockCommands) MilestonePushed(ctx context.Context, instanceID string, msType milestone.Type, endpoints []string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MilestonePushed", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "MilestonePushed", ctx, instanceID, msType, endpoints) ret0, _ := ret[0].(error) return ret0 } // MilestonePushed indicates an expected call of MilestonePushed. -func (mr *MockCommandsMockRecorder) MilestonePushed(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) MilestonePushed(ctx, instanceID, msType, endpoints any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), ctx, instanceID, msType, endpoints) } // OTPEmailSent mocks base method. -func (m *MockCommands) OTPEmailSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OTPEmailSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "OTPEmailSent", ctx, sessionID, resourceOwner) ret0, _ := ret[0].(error) return ret0 } // OTPEmailSent indicates an expected call of OTPEmailSent. -func (mr *MockCommandsMockRecorder) OTPEmailSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) OTPEmailSent(ctx, sessionID, resourceOwner any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPEmailSent", reflect.TypeOf((*MockCommands)(nil).OTPEmailSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPEmailSent", reflect.TypeOf((*MockCommands)(nil).OTPEmailSent), ctx, sessionID, resourceOwner) } // OTPSMSSent mocks base method. -func (m *MockCommands) OTPSMSSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { +func (m *MockCommands) OTPSMSSent(ctx context.Context, sessionID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OTPSMSSent", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "OTPSMSSent", ctx, sessionID, resourceOwner, generatorInfo) ret0, _ := ret[0].(error) return ret0 } // OTPSMSSent indicates an expected call of OTPSMSSent. -func (mr *MockCommandsMockRecorder) OTPSMSSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) OTPSMSSent(ctx, sessionID, resourceOwner, generatorInfo any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPSMSSent", reflect.TypeOf((*MockCommands)(nil).OTPSMSSent), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPSMSSent", reflect.TypeOf((*MockCommands)(nil).OTPSMSSent), ctx, sessionID, resourceOwner, generatorInfo) } // PasswordChangeSent mocks base method. -func (m *MockCommands) PasswordChangeSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) PasswordChangeSent(ctx context.Context, orgID, userID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PasswordChangeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "PasswordChangeSent", ctx, orgID, userID) ret0, _ := ret[0].(error) return ret0 } // PasswordChangeSent indicates an expected call of PasswordChangeSent. -func (mr *MockCommandsMockRecorder) PasswordChangeSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) PasswordChangeSent(ctx, orgID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordChangeSent", reflect.TypeOf((*MockCommands)(nil).PasswordChangeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordChangeSent", reflect.TypeOf((*MockCommands)(nil).PasswordChangeSent), ctx, orgID, userID) } // PasswordCodeSent mocks base method. -func (m *MockCommands) PasswordCodeSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { +func (m *MockCommands) PasswordCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PasswordCodeSent", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "PasswordCodeSent", ctx, orgID, userID, generatorInfo) ret0, _ := ret[0].(error) return ret0 } // PasswordCodeSent indicates an expected call of PasswordCodeSent. -func (mr *MockCommandsMockRecorder) PasswordCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) PasswordCodeSent(ctx, orgID, userID, generatorInfo any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), ctx, orgID, userID, generatorInfo) } // UsageNotificationSent mocks base method. -func (m *MockCommands) UsageNotificationSent(arg0 context.Context, arg1 *quota.NotificationDueEvent) error { +func (m *MockCommands) UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UsageNotificationSent", arg0, arg1) + ret := m.ctrl.Call(m, "UsageNotificationSent", ctx, dueEvent) ret0, _ := ret[0].(error) return ret0 } // UsageNotificationSent indicates an expected call of UsageNotificationSent. -func (mr *MockCommandsMockRecorder) UsageNotificationSent(arg0, arg1 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) UsageNotificationSent(ctx, dueEvent any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsageNotificationSent", reflect.TypeOf((*MockCommands)(nil).UsageNotificationSent), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsageNotificationSent", reflect.TypeOf((*MockCommands)(nil).UsageNotificationSent), ctx, dueEvent) } // UserDomainClaimedSent mocks base method. -func (m *MockCommands) UserDomainClaimedSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) UserDomainClaimedSent(ctx context.Context, orgID, userID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UserDomainClaimedSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "UserDomainClaimedSent", ctx, orgID, userID) ret0, _ := ret[0].(error) return ret0 } // UserDomainClaimedSent indicates an expected call of UserDomainClaimedSent. -func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(ctx, orgID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserDomainClaimedSent", reflect.TypeOf((*MockCommands)(nil).UserDomainClaimedSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserDomainClaimedSent", reflect.TypeOf((*MockCommands)(nil).UserDomainClaimedSent), ctx, orgID, userID) } diff --git a/internal/notification/handlers/mock/queries.mock.go b/internal/notification/handlers/mock/queries.mock.go index 670d3f3896..2cf53d1b2a 100644 --- a/internal/notification/handlers/mock/queries.mock.go +++ b/internal/notification/handlers/mock/queries.mock.go @@ -12,7 +12,6 @@ package mock import ( context "context" reflect "reflect" - time "time" jose "github.com/go-jose/go-jose/v4" authz "github.com/zitadel/zitadel/internal/api/authz" @@ -26,6 +25,7 @@ import ( type MockQueries struct { ctrl *gomock.Controller recorder *MockQueriesMockRecorder + isgomock struct{} } // MockQueriesMockRecorder is the mock recorder for MockQueries. @@ -60,240 +60,225 @@ func (mr *MockQueriesMockRecorder) ActiveInstances() *gomock.Call { } // ActiveLabelPolicyByOrg mocks base method. -func (m *MockQueries) ActiveLabelPolicyByOrg(arg0 context.Context, arg1 string, arg2 bool) (*query.LabelPolicy, error) { +func (m *MockQueries) ActiveLabelPolicyByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.LabelPolicy, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ActiveLabelPolicyByOrg", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "ActiveLabelPolicyByOrg", ctx, orgID, withOwnerRemoved) ret0, _ := ret[0].(*query.LabelPolicy) ret1, _ := ret[1].(error) return ret0, ret1 } // ActiveLabelPolicyByOrg indicates an expected call of ActiveLabelPolicyByOrg. -func (mr *MockQueriesMockRecorder) ActiveLabelPolicyByOrg(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) ActiveLabelPolicyByOrg(ctx, orgID, withOwnerRemoved any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveLabelPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).ActiveLabelPolicyByOrg), arg0, arg1, arg2) -} - -// ActivePrivateSigningKey mocks base method. -func (m *MockQueries) ActivePrivateSigningKey(arg0 context.Context, arg1 time.Time) (*query.PrivateKeys, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ActivePrivateSigningKey", arg0, arg1) - ret0, _ := ret[0].(*query.PrivateKeys) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ActivePrivateSigningKey indicates an expected call of ActivePrivateSigningKey. -func (mr *MockQueriesMockRecorder) ActivePrivateSigningKey(arg0, arg1 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActivePrivateSigningKey", reflect.TypeOf((*MockQueries)(nil).ActivePrivateSigningKey), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveLabelPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).ActiveLabelPolicyByOrg), ctx, orgID, withOwnerRemoved) } // CustomTextListByTemplate mocks base method. -func (m *MockQueries) CustomTextListByTemplate(arg0 context.Context, arg1, arg2 string, arg3 bool) (*query.CustomTexts, error) { +func (m *MockQueries) CustomTextListByTemplate(ctx context.Context, aggregateID, template string, withOwnerRemoved bool) (*query.CustomTexts, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CustomTextListByTemplate", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "CustomTextListByTemplate", ctx, aggregateID, template, withOwnerRemoved) ret0, _ := ret[0].(*query.CustomTexts) ret1, _ := ret[1].(error) return ret0, ret1 } // CustomTextListByTemplate indicates an expected call of CustomTextListByTemplate. -func (mr *MockQueriesMockRecorder) CustomTextListByTemplate(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) CustomTextListByTemplate(ctx, aggregateID, template, withOwnerRemoved any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomTextListByTemplate", reflect.TypeOf((*MockQueries)(nil).CustomTextListByTemplate), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomTextListByTemplate", reflect.TypeOf((*MockQueries)(nil).CustomTextListByTemplate), ctx, aggregateID, template, withOwnerRemoved) } // GetActiveSigningWebKey mocks base method. -func (m *MockQueries) GetActiveSigningWebKey(arg0 context.Context) (*jose.JSONWebKey, error) { +func (m *MockQueries) GetActiveSigningWebKey(ctx context.Context) (*jose.JSONWebKey, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetActiveSigningWebKey", arg0) + ret := m.ctrl.Call(m, "GetActiveSigningWebKey", ctx) ret0, _ := ret[0].(*jose.JSONWebKey) ret1, _ := ret[1].(error) return ret0, ret1 } // GetActiveSigningWebKey indicates an expected call of GetActiveSigningWebKey. -func (mr *MockQueriesMockRecorder) GetActiveSigningWebKey(arg0 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetActiveSigningWebKey(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveSigningWebKey", reflect.TypeOf((*MockQueries)(nil).GetActiveSigningWebKey), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveSigningWebKey", reflect.TypeOf((*MockQueries)(nil).GetActiveSigningWebKey), ctx) } // GetDefaultLanguage mocks base method. -func (m *MockQueries) GetDefaultLanguage(arg0 context.Context) language.Tag { +func (m *MockQueries) GetDefaultLanguage(ctx context.Context) language.Tag { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetDefaultLanguage", arg0) + ret := m.ctrl.Call(m, "GetDefaultLanguage", ctx) ret0, _ := ret[0].(language.Tag) return ret0 } // GetDefaultLanguage indicates an expected call of GetDefaultLanguage. -func (mr *MockQueriesMockRecorder) GetDefaultLanguage(arg0 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetDefaultLanguage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultLanguage", reflect.TypeOf((*MockQueries)(nil).GetDefaultLanguage), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultLanguage", reflect.TypeOf((*MockQueries)(nil).GetDefaultLanguage), ctx) } // GetInstanceRestrictions mocks base method. -func (m *MockQueries) GetInstanceRestrictions(arg0 context.Context) (query.Restrictions, error) { +func (m *MockQueries) GetInstanceRestrictions(ctx context.Context) (query.Restrictions, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetInstanceRestrictions", arg0) + ret := m.ctrl.Call(m, "GetInstanceRestrictions", ctx) ret0, _ := ret[0].(query.Restrictions) ret1, _ := ret[1].(error) return ret0, ret1 } // GetInstanceRestrictions indicates an expected call of GetInstanceRestrictions. -func (mr *MockQueriesMockRecorder) GetInstanceRestrictions(arg0 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetInstanceRestrictions(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstanceRestrictions", reflect.TypeOf((*MockQueries)(nil).GetInstanceRestrictions), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstanceRestrictions", reflect.TypeOf((*MockQueries)(nil).GetInstanceRestrictions), ctx) } // GetNotifyUserByID mocks base method. -func (m *MockQueries) GetNotifyUserByID(arg0 context.Context, arg1 bool, arg2 string) (*query.NotifyUser, error) { +func (m *MockQueries) GetNotifyUserByID(ctx context.Context, shouldTriggered bool, userID string) (*query.NotifyUser, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetNotifyUserByID", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetNotifyUserByID", ctx, shouldTriggered, userID) ret0, _ := ret[0].(*query.NotifyUser) ret1, _ := ret[1].(error) return ret0, ret1 } // GetNotifyUserByID indicates an expected call of GetNotifyUserByID. -func (mr *MockQueriesMockRecorder) GetNotifyUserByID(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetNotifyUserByID(ctx, shouldTriggered, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotifyUserByID", reflect.TypeOf((*MockQueries)(nil).GetNotifyUserByID), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotifyUserByID", reflect.TypeOf((*MockQueries)(nil).GetNotifyUserByID), ctx, shouldTriggered, userID) } // InstanceByID mocks base method. -func (m *MockQueries) InstanceByID(arg0 context.Context, arg1 string) (authz.Instance, error) { +func (m *MockQueries) InstanceByID(ctx context.Context, id string) (authz.Instance, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InstanceByID", arg0, arg1) + ret := m.ctrl.Call(m, "InstanceByID", ctx, id) ret0, _ := ret[0].(authz.Instance) ret1, _ := ret[1].(error) return ret0, ret1 } // InstanceByID indicates an expected call of InstanceByID. -func (mr *MockQueriesMockRecorder) InstanceByID(arg0, arg1 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) InstanceByID(ctx, id any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceByID", reflect.TypeOf((*MockQueries)(nil).InstanceByID), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceByID", reflect.TypeOf((*MockQueries)(nil).InstanceByID), ctx, id) } // MailTemplateByOrg mocks base method. -func (m *MockQueries) MailTemplateByOrg(arg0 context.Context, arg1 string, arg2 bool) (*query.MailTemplate, error) { +func (m *MockQueries) MailTemplateByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.MailTemplate, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MailTemplateByOrg", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "MailTemplateByOrg", ctx, orgID, withOwnerRemoved) ret0, _ := ret[0].(*query.MailTemplate) ret1, _ := ret[1].(error) return ret0, ret1 } // MailTemplateByOrg indicates an expected call of MailTemplateByOrg. -func (mr *MockQueriesMockRecorder) MailTemplateByOrg(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) MailTemplateByOrg(ctx, orgID, withOwnerRemoved any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MailTemplateByOrg", reflect.TypeOf((*MockQueries)(nil).MailTemplateByOrg), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MailTemplateByOrg", reflect.TypeOf((*MockQueries)(nil).MailTemplateByOrg), ctx, orgID, withOwnerRemoved) } // NotificationPolicyByOrg mocks base method. -func (m *MockQueries) NotificationPolicyByOrg(arg0 context.Context, arg1 bool, arg2 string, arg3 bool) (*query.NotificationPolicy, error) { +func (m *MockQueries) NotificationPolicyByOrg(ctx context.Context, shouldTriggerBulk bool, orgID string, withOwnerRemoved bool) (*query.NotificationPolicy, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NotificationPolicyByOrg", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "NotificationPolicyByOrg", ctx, shouldTriggerBulk, orgID, withOwnerRemoved) ret0, _ := ret[0].(*query.NotificationPolicy) ret1, _ := ret[1].(error) return ret0, ret1 } // NotificationPolicyByOrg indicates an expected call of NotificationPolicyByOrg. -func (mr *MockQueriesMockRecorder) NotificationPolicyByOrg(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) NotificationPolicyByOrg(ctx, shouldTriggerBulk, orgID, withOwnerRemoved any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).NotificationPolicyByOrg), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).NotificationPolicyByOrg), ctx, shouldTriggerBulk, orgID, withOwnerRemoved) } // NotificationProviderByIDAndType mocks base method. -func (m *MockQueries) NotificationProviderByIDAndType(arg0 context.Context, arg1 string, arg2 domain.NotificationProviderType) (*query.DebugNotificationProvider, error) { +func (m *MockQueries) NotificationProviderByIDAndType(ctx context.Context, aggID string, providerType domain.NotificationProviderType) (*query.DebugNotificationProvider, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NotificationProviderByIDAndType", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "NotificationProviderByIDAndType", ctx, aggID, providerType) ret0, _ := ret[0].(*query.DebugNotificationProvider) ret1, _ := ret[1].(error) return ret0, ret1 } // NotificationProviderByIDAndType indicates an expected call of NotificationProviderByIDAndType. -func (mr *MockQueriesMockRecorder) NotificationProviderByIDAndType(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) NotificationProviderByIDAndType(ctx, aggID, providerType any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationProviderByIDAndType", reflect.TypeOf((*MockQueries)(nil).NotificationProviderByIDAndType), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationProviderByIDAndType", reflect.TypeOf((*MockQueries)(nil).NotificationProviderByIDAndType), ctx, aggID, providerType) } // SMSProviderConfigActive mocks base method. -func (m *MockQueries) SMSProviderConfigActive(arg0 context.Context, arg1 string) (*query.SMSConfig, error) { +func (m *MockQueries) SMSProviderConfigActive(ctx context.Context, resourceOwner string) (*query.SMSConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SMSProviderConfigActive", arg0, arg1) + ret := m.ctrl.Call(m, "SMSProviderConfigActive", ctx, resourceOwner) ret0, _ := ret[0].(*query.SMSConfig) ret1, _ := ret[1].(error) return ret0, ret1 } // SMSProviderConfigActive indicates an expected call of SMSProviderConfigActive. -func (mr *MockQueriesMockRecorder) SMSProviderConfigActive(arg0, arg1 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SMSProviderConfigActive(ctx, resourceOwner any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfigActive", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfigActive), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfigActive", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfigActive), ctx, resourceOwner) } // SMTPConfigActive mocks base method. -func (m *MockQueries) SMTPConfigActive(arg0 context.Context, arg1 string) (*query.SMTPConfig, error) { +func (m *MockQueries) SMTPConfigActive(ctx context.Context, resourceOwner string) (*query.SMTPConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SMTPConfigActive", arg0, arg1) + ret := m.ctrl.Call(m, "SMTPConfigActive", ctx, resourceOwner) ret0, _ := ret[0].(*query.SMTPConfig) ret1, _ := ret[1].(error) return ret0, ret1 } // SMTPConfigActive indicates an expected call of SMTPConfigActive. -func (mr *MockQueriesMockRecorder) SMTPConfigActive(arg0, arg1 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SMTPConfigActive(ctx, resourceOwner any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMTPConfigActive", reflect.TypeOf((*MockQueries)(nil).SMTPConfigActive), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMTPConfigActive", reflect.TypeOf((*MockQueries)(nil).SMTPConfigActive), ctx, resourceOwner) } // SearchInstanceDomains mocks base method. -func (m *MockQueries) SearchInstanceDomains(arg0 context.Context, arg1 *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) { +func (m *MockQueries) SearchInstanceDomains(ctx context.Context, queries *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SearchInstanceDomains", arg0, arg1) + ret := m.ctrl.Call(m, "SearchInstanceDomains", ctx, queries) ret0, _ := ret[0].(*query.InstanceDomains) ret1, _ := ret[1].(error) return ret0, ret1 } // SearchInstanceDomains indicates an expected call of SearchInstanceDomains. -func (mr *MockQueriesMockRecorder) SearchInstanceDomains(arg0, arg1 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SearchInstanceDomains(ctx, queries any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchInstanceDomains", reflect.TypeOf((*MockQueries)(nil).SearchInstanceDomains), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchInstanceDomains", reflect.TypeOf((*MockQueries)(nil).SearchInstanceDomains), ctx, queries) } // SearchMilestones mocks base method. -func (m *MockQueries) SearchMilestones(arg0 context.Context, arg1 []string, arg2 *query.MilestonesSearchQueries) (*query.Milestones, error) { +func (m *MockQueries) SearchMilestones(ctx context.Context, instanceIDs []string, queries *query.MilestonesSearchQueries) (*query.Milestones, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SearchMilestones", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "SearchMilestones", ctx, instanceIDs, queries) ret0, _ := ret[0].(*query.Milestones) ret1, _ := ret[1].(error) return ret0, ret1 } // SearchMilestones indicates an expected call of SearchMilestones. -func (mr *MockQueriesMockRecorder) SearchMilestones(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SearchMilestones(ctx, instanceIDs, queries any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchMilestones", reflect.TypeOf((*MockQueries)(nil).SearchMilestones), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchMilestones", reflect.TypeOf((*MockQueries)(nil).SearchMilestones), ctx, instanceIDs, queries) } // SessionByID mocks base method. -func (m *MockQueries) SessionByID(arg0 context.Context, arg1 bool, arg2, arg3 string, arg4 domain.PermissionCheck) (*query.Session, error) { +func (m *MockQueries) SessionByID(ctx context.Context, shouldTriggerBulk bool, id, sessionToken string, check domain.PermissionCheck) (*query.Session, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SessionByID", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "SessionByID", ctx, shouldTriggerBulk, id, sessionToken, check) ret0, _ := ret[0].(*query.Session) ret1, _ := ret[1].(error) return ret0, ret1 } // SessionByID indicates an expected call of SessionByID. -func (mr *MockQueriesMockRecorder) SessionByID(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SessionByID(ctx, shouldTriggerBulk, id, sessionToken, check any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionByID", reflect.TypeOf((*MockQueries)(nil).SessionByID), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionByID", reflect.TypeOf((*MockQueries)(nil).SessionByID), ctx, shouldTriggerBulk, id, sessionToken, check) } diff --git a/internal/notification/handlers/mock/queue.mock.go b/internal/notification/handlers/mock/queue.mock.go index e1387595db..e9cf3efed1 100644 --- a/internal/notification/handlers/mock/queue.mock.go +++ b/internal/notification/handlers/mock/queue.mock.go @@ -22,6 +22,7 @@ import ( type MockQueue struct { ctrl *gomock.Controller recorder *MockQueueMockRecorder + isgomock struct{} } // MockQueueMockRecorder is the mock recorder for MockQueue. @@ -42,10 +43,10 @@ func (m *MockQueue) EXPECT() *MockQueueMockRecorder { } // Insert mocks base method. -func (m *MockQueue) Insert(arg0 context.Context, arg1 river.JobArgs, arg2 ...queue.InsertOpt) error { +func (m *MockQueue) Insert(ctx context.Context, args river.JobArgs, opts ...queue.InsertOpt) error { m.ctrl.T.Helper() - varargs := []any{arg0, arg1} - for _, a := range arg2 { + varargs := []any{ctx, args} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Insert", varargs...) @@ -54,8 +55,8 @@ func (m *MockQueue) Insert(arg0 context.Context, arg1 river.JobArgs, arg2 ...que } // Insert indicates an expected call of Insert. -func (mr *MockQueueMockRecorder) Insert(arg0, arg1 any, arg2 ...any) *gomock.Call { +func (mr *MockQueueMockRecorder) Insert(ctx, args any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1}, arg2...) + varargs := append([]any{ctx, args}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockQueue)(nil).Insert), varargs...) } diff --git a/internal/notification/handlers/queries.go b/internal/notification/handlers/queries.go index a3d68e4797..d9ff1b4201 100644 --- a/internal/notification/handlers/queries.go +++ b/internal/notification/handlers/queries.go @@ -2,7 +2,6 @@ package handlers import ( "context" - "time" "github.com/go-jose/go-jose/v4" "golang.org/x/text/language" @@ -30,7 +29,6 @@ type Queries interface { GetInstanceRestrictions(ctx context.Context) (restrictions query.Restrictions, err error) InstanceByID(ctx context.Context, id string) (instance authz.Instance, err error) GetActiveSigningWebKey(ctx context.Context) (*jose.JSONWebKey, error) - ActivePrivateSigningKey(ctx context.Context, t time.Time) (keys *query.PrivateKeys, err error) ActiveInstances() []string } diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go index 501cfc4e9c..9e0081a542 100644 --- a/internal/query/instance_features.go +++ b/internal/query/instance_features.go @@ -14,7 +14,6 @@ type InstanceFeatures struct { UserSchema FeatureSource[bool] TokenExchange FeatureSource[bool] ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] - WebKey FeatureSource[bool] DebugOIDCParentError FeatureSource[bool] OIDCSingleV1SessionTermination FeatureSource[bool] DisableUserTokenEvent FeatureSource[bool] diff --git a/internal/query/instance_features_model.go b/internal/query/instance_features_model.go index 7130044fbf..a30009e9ee 100644 --- a/internal/query/instance_features_model.go +++ b/internal/query/instance_features_model.go @@ -67,7 +67,6 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, feature_v2.InstanceImprovedPerformanceEventType, - feature_v2.InstanceWebKeyEventType, feature_v2.InstanceDebugOIDCParentErrorEventType, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType, feature_v2.InstanceDisableUserTokenEvent, @@ -121,8 +120,6 @@ func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_ features.TokenExchange.set(level, event.Value) case feature.KeyImprovedPerformance: features.ImprovedPerformance.set(level, event.Value) - case feature.KeyWebKey: - features.WebKey.set(level, event.Value) case feature.KeyDebugOIDCParentError: features.DebugOIDCParentError.set(level, event.Value) case feature.KeyOIDCSingleV1SessionTermination: diff --git a/internal/query/key.go b/internal/query/key.go index 4831d88654..e7b81bb951 100644 --- a/internal/query/key.go +++ b/internal/query/key.go @@ -1,20 +1,10 @@ package query import ( - "context" - "crypto/rsa" - "database/sql" "time" - sq "github.com/Masterminds/squirrel" - - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/query/projection" - "github.com/zitadel/zitadel/internal/repository/keypair" - "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/zerrors" ) type Key interface { @@ -36,11 +26,6 @@ type PublicKey interface { Key() interface{} } -type PrivateKeys struct { - SearchResponse - Keys []PrivateKey -} - type PublicKeys struct { SearchResponse Keys []PublicKey @@ -72,34 +57,6 @@ func (k *key) Sequence() uint64 { return k.sequence } -type privateKey struct { - key - expiry time.Time - privateKey *crypto.CryptoValue -} - -func (k *privateKey) Expiry() time.Time { - return k.expiry -} - -func (k *privateKey) Key() *crypto.CryptoValue { - return k.privateKey -} - -type rsaPublicKey struct { - key - expiry time.Time - publicKey *rsa.PublicKey -} - -func (r *rsaPublicKey) Expiry() time.Time { - return r.expiry -} - -func (r *rsaPublicKey) Key() interface{} { - return r.publicKey -} - var ( keyTable = table{ name: projection.KeyProjectionTable, @@ -157,277 +114,3 @@ var ( table: keyPrivateTable, } ) - -var ( - keyPublicTable = table{ - name: projection.KeyPublicTable, - instanceIDCol: projection.KeyPrivateColumnInstanceID, - } - KeyPublicColID = Column{ - name: projection.KeyPublicColumnID, - table: keyPublicTable, - } - KeyPublicColExpiry = Column{ - name: projection.KeyPublicColumnExpiry, - table: keyPublicTable, - } - KeyPublicColKey = Column{ - name: projection.KeyPublicColumnKey, - table: keyPublicTable, - } -) - -func (q *Queries) ActivePublicKeys(ctx context.Context, t time.Time) (keys *PublicKeys, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - query, scan := preparePublicKeysQuery() - if t.IsZero() { - t = time.Now() - } - stmt, args, err := query.Where( - sq.And{ - sq.Eq{KeyColInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()}, - sq.Gt{KeyPublicColExpiry.identifier(): t}, - }).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-SDFfg", "Errors.Query.SQLStatement") - } - - err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { - keys, err = scan(rows) - return err - }, stmt, args...) - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Sghn4", "Errors.Internal") - } - - keys.State, err = q.latestState(ctx, keyTable) - if !zerrors.IsNotFound(err) { - return keys, err - } - return keys, nil -} - -func (q *Queries) ActivePrivateSigningKey(ctx context.Context, t time.Time) (keys *PrivateKeys, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - stmt, scan := preparePrivateKeysQuery() - if t.IsZero() { - t = time.Now() - } - query, args, err := stmt.Where( - sq.And{ - sq.Eq{ - KeyColUse.identifier(): crypto.KeyUsageSigning, - KeyColInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), - }, - sq.Gt{KeyPrivateColExpiry.identifier(): t}, - }).OrderBy(KeyPrivateColExpiry.identifier()).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-SDff2", "Errors.Query.SQLStatement") - } - - err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { - keys, err = scan(rows) - return err - }, query, args...) - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-WRFG4", "Errors.Internal") - } - keys.State, err = q.latestState(ctx, keyTable) - if !zerrors.IsNotFound(err) { - return keys, err - } - return keys, nil -} - -func preparePublicKeysQuery() (sq.SelectBuilder, func(*sql.Rows) (*PublicKeys, error)) { - return sq.Select( - KeyColID.identifier(), - KeyColCreationDate.identifier(), - KeyColChangeDate.identifier(), - KeyColSequence.identifier(), - KeyColResourceOwner.identifier(), - KeyColAlgorithm.identifier(), - KeyColUse.identifier(), - KeyPublicColExpiry.identifier(), - KeyPublicColKey.identifier(), - countColumn.identifier(), - ).From(keyTable.identifier()). - LeftJoin(join(KeyPublicColID, KeyColID)). - PlaceholderFormat(sq.Dollar), - func(rows *sql.Rows) (*PublicKeys, error) { - keys := make([]PublicKey, 0) - var count uint64 - for rows.Next() { - k := new(rsaPublicKey) - var keyValue []byte - err := rows.Scan( - &k.id, - &k.creationDate, - &k.changeDate, - &k.sequence, - &k.resourceOwner, - &k.algorithm, - &k.use, - &k.expiry, - &keyValue, - &count, - ) - if err != nil { - return nil, err - } - k.publicKey, err = crypto.BytesToPublicKey(keyValue) - if err != nil { - return nil, err - } - keys = append(keys, k) - } - - if err := rows.Close(); err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-rKd6k", "Errors.Query.CloseRows") - } - - return &PublicKeys{ - Keys: keys, - SearchResponse: SearchResponse{ - Count: count, - }, - }, nil - } -} - -func preparePrivateKeysQuery() (sq.SelectBuilder, func(*sql.Rows) (*PrivateKeys, error)) { - return sq.Select( - KeyColID.identifier(), - KeyColCreationDate.identifier(), - KeyColChangeDate.identifier(), - KeyColSequence.identifier(), - KeyColResourceOwner.identifier(), - KeyColAlgorithm.identifier(), - KeyColUse.identifier(), - KeyPrivateColExpiry.identifier(), - KeyPrivateColKey.identifier(), - countColumn.identifier(), - ).From(keyTable.identifier()). - LeftJoin(join(KeyPrivateColID, KeyColID)). - PlaceholderFormat(sq.Dollar), - func(rows *sql.Rows) (*PrivateKeys, error) { - keys := make([]PrivateKey, 0) - var count uint64 - for rows.Next() { - k := new(privateKey) - err := rows.Scan( - &k.id, - &k.creationDate, - &k.changeDate, - &k.sequence, - &k.resourceOwner, - &k.algorithm, - &k.use, - &k.expiry, - &k.privateKey, - &count, - ) - if err != nil { - return nil, err - } - keys = append(keys, k) - } - - if err := rows.Close(); err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-rKd6k", "Errors.Query.CloseRows") - } - - return &PrivateKeys{ - Keys: keys, - SearchResponse: SearchResponse{ - Count: count, - }, - }, nil - } -} - -type PublicKeyReadModel struct { - eventstore.ReadModel - - Algorithm string - Key *crypto.CryptoValue - Expiry time.Time - Usage crypto.KeyUsage -} - -func NewPublicKeyReadModel(keyID, resourceOwner string) *PublicKeyReadModel { - return &PublicKeyReadModel{ - ReadModel: eventstore.ReadModel{ - AggregateID: keyID, - ResourceOwner: resourceOwner, - }, - } -} - -func (wm *PublicKeyReadModel) AppendEvents(events ...eventstore.Event) { - wm.ReadModel.AppendEvents(events...) -} - -func (wm *PublicKeyReadModel) Reduce() error { - for _, event := range wm.Events { - switch e := event.(type) { - case *keypair.AddedEvent: - wm.Algorithm = e.Algorithm - wm.Key = e.PublicKey.Key - wm.Expiry = e.PublicKey.Expiry - wm.Usage = e.Usage - default: - } - } - return wm.ReadModel.Reduce() -} - -func (wm *PublicKeyReadModel) Query() *eventstore.SearchQueryBuilder { - return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). - AwaitOpenTransactions(). - ResourceOwner(wm.ResourceOwner). - AddQuery(). - AggregateTypes(keypair.AggregateType). - AggregateIDs(wm.AggregateID). - EventTypes(keypair.AddedEventType). - Builder() -} - -func (q *Queries) GetPublicKeyByID(ctx context.Context, keyID string) (_ PublicKey, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - model := NewPublicKeyReadModel(keyID, authz.GetInstance(ctx).InstanceID()) - if err := q.eventstore.FilterToQueryReducer(ctx, model); err != nil { - return nil, err - } - if model.Algorithm == "" || model.Key == nil { - return nil, zerrors.ThrowNotFound(err, "QUERY-Ahf7x", "Errors.Key.NotFound") - } - keyValue, err := crypto.Decrypt(model.Key, q.keyEncryptionAlgorithm) - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Ie4oh", "Errors.Internal") - } - publicKey, err := crypto.BytesToPublicKey(keyValue) - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Kai2Z", "Errors.Internal") - } - - return &rsaPublicKey{ - key: key{ - id: model.AggregateID, - creationDate: model.CreationDate, - changeDate: model.ChangeDate, - sequence: model.ProcessedSequence, - resourceOwner: model.ResourceOwner, - algorithm: model.Algorithm, - use: model.Usage, - }, - expiry: model.Expiry, - publicKey: publicKey, - }, nil -} diff --git a/internal/query/key_test.go b/internal/query/key_test.go deleted file mode 100644 index 7bc029fd7f..0000000000 --- a/internal/query/key_test.go +++ /dev/null @@ -1,453 +0,0 @@ -package query - -import ( - "context" - "crypto/rsa" - "database/sql" - "database/sql/driver" - "errors" - "fmt" - "io" - "math/big" - "regexp" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" - - "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/eventstore" - key_repo "github.com/zitadel/zitadel/internal/repository/keypair" - "github.com/zitadel/zitadel/internal/zerrors" -) - -var ( - preparePublicKeysStmt = `SELECT projections.keys4.id,` + - ` projections.keys4.creation_date,` + - ` projections.keys4.change_date,` + - ` projections.keys4.sequence,` + - ` projections.keys4.resource_owner,` + - ` projections.keys4.algorithm,` + - ` projections.keys4.use,` + - ` projections.keys4_public.expiry,` + - ` projections.keys4_public.key,` + - ` COUNT(*) OVER ()` + - ` FROM projections.keys4` + - ` LEFT JOIN projections.keys4_public ON projections.keys4.id = projections.keys4_public.id AND projections.keys4.instance_id = projections.keys4_public.instance_id` - preparePublicKeysCols = []string{ - "id", - "creation_date", - "change_date", - "sequence", - "resource_owner", - "algorithm", - "use", - "expiry", - "key", - "count", - } - - preparePrivateKeysStmt = `SELECT projections.keys4.id,` + - ` projections.keys4.creation_date,` + - ` projections.keys4.change_date,` + - ` projections.keys4.sequence,` + - ` projections.keys4.resource_owner,` + - ` projections.keys4.algorithm,` + - ` projections.keys4.use,` + - ` projections.keys4_private.expiry,` + - ` projections.keys4_private.key,` + - ` COUNT(*) OVER ()` + - ` FROM projections.keys4` + - ` LEFT JOIN projections.keys4_private ON projections.keys4.id = projections.keys4_private.id AND projections.keys4.instance_id = projections.keys4_private.instance_id` -) - -func Test_KeyPrepares(t *testing.T) { - type want struct { - sqlExpectations sqlExpectation - err checkErr - } - tests := []struct { - name string - prepare interface{} - want want - object interface{} - }{ - { - name: "preparePublicKeysQuery no result", - prepare: preparePublicKeysQuery, - want: want{ - sqlExpectations: mockQueries( - regexp.QuoteMeta(preparePublicKeysStmt), - nil, - nil, - ), - err: func(err error) (error, bool) { - if !zerrors.IsNotFound(err) { - return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false - } - return nil, true - }, - }, - object: &PublicKeys{Keys: []PublicKey{}}, - }, - { - name: "preparePublicKeysQuery found", - prepare: preparePublicKeysQuery, - want: want{ - sqlExpectations: mockQueries( - regexp.QuoteMeta(preparePublicKeysStmt), - preparePublicKeysCols, - [][]driver.Value{ - { - "key-id", - testNow, - testNow, - uint64(20211109), - "ro", - "RS256", - 0, - testNow, - []byte("-----BEGIN RSA PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsvX9P58JFxEs5C+L+H7W\nduFSWL5EPzber7C2m94klrSV6q0bAcrYQnGwFOlveThsY200hRbadKaKjHD7qIKH\nDEe0IY2PSRht33Jye52AwhkRw+M3xuQH/7R8LydnsNFk2KHpr5X2SBv42e37LjkE\nslKSaMRgJW+v0KZ30piY8QsdFRKKaVg5/Ajt1YToM1YVsdHXJ3vmXFMtypLdxwUD\ndIaLEX6pFUkU75KSuEQ/E2luT61Q3ta9kOWm9+0zvi7OMcbdekJT7mzcVnh93R1c\n13ZhQCLbh9A7si8jKFtaMWevjayrvqQABEcTN9N4Hoxcyg6l4neZtRDk75OMYcqm\nDQIDAQAB\n-----END RSA PUBLIC KEY-----\n"), - }, - }, - ), - }, - object: &PublicKeys{ - SearchResponse: SearchResponse{ - Count: 1, - }, - Keys: []PublicKey{ - &rsaPublicKey{ - key: key{ - id: "key-id", - creationDate: testNow, - changeDate: testNow, - sequence: 20211109, - resourceOwner: "ro", - algorithm: "RS256", - use: crypto.KeyUsageSigning, - }, - expiry: testNow, - publicKey: &rsa.PublicKey{ - E: 65537, - N: fromBase16("b2f5fd3f9f0917112ce42f8bf87ed676e15258be443f36deafb0b69bde2496b495eaad1b01cad84271b014e96f79386c636d348516da74a68a8c70fba882870c47b4218d8f49186ddf72727b9d80c21911c3e337c6e407ffb47c2f2767b0d164d8a1e9af95f6481bf8d9edfb2e3904b2529268c460256fafd0a677d29898f10b1d15128a695839fc08edd584e8335615b1d1d7277be65c532dca92ddc7050374868b117ea9154914ef9292b8443f13696e4fad50ded6bd90e5a6f7ed33be2ece31c6dd7a4253ee6cdc56787ddd1d5cd776614022db87d03bb22f23285b5a3167af8dacabbea40004471337d3781e8c5cca0ea5e27799b510e4ef938c61caa60d"), - }, - }, - }, - }, - }, - { - name: "preparePublicKeysQuery sql err", - prepare: preparePublicKeysQuery, - want: want{ - sqlExpectations: mockQueryErr( - regexp.QuoteMeta(preparePublicKeysStmt), - sql.ErrConnDone, - ), - err: func(err error) (error, bool) { - if !errors.Is(err, sql.ErrConnDone) { - return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false - } - return nil, true - }, - }, - object: (*PublicKeys)(nil), - }, - { - name: "preparePrivateKeysQuery no result", - prepare: preparePrivateKeysQuery, - want: want{ - sqlExpectations: mockQueries( - regexp.QuoteMeta(preparePrivateKeysStmt), - nil, - nil, - ), - err: func(err error) (error, bool) { - if !zerrors.IsNotFound(err) { - return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false - } - return nil, true - }, - }, - object: &PrivateKeys{Keys: []PrivateKey{}}, - }, - { - name: "preparePrivateKeysQuery found", - prepare: preparePrivateKeysQuery, - want: want{ - sqlExpectations: mockQueries( - regexp.QuoteMeta(preparePrivateKeysStmt), - preparePublicKeysCols, - [][]driver.Value{ - { - "key-id", - testNow, - testNow, - uint64(20211109), - "ro", - "RS256", - 0, - testNow, - []byte(`{"Algorithm": "enc", "Crypted": "cHJpdmF0ZUtleQ==", "CryptoType": 0, "KeyID": "id"}`), - }, - }, - ), - }, - object: &PrivateKeys{ - SearchResponse: SearchResponse{ - Count: 1, - }, - Keys: []PrivateKey{ - &privateKey{ - key: key{ - id: "key-id", - creationDate: testNow, - changeDate: testNow, - sequence: 20211109, - resourceOwner: "ro", - algorithm: "RS256", - use: crypto.KeyUsageSigning, - }, - expiry: testNow, - privateKey: &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("privateKey"), - }, - }, - }, - }, - }, - { - name: "preparePrivateKeysQuery sql err", - prepare: preparePrivateKeysQuery, - want: want{ - sqlExpectations: mockQueryErr( - regexp.QuoteMeta(preparePrivateKeysStmt), - sql.ErrConnDone, - ), - err: func(err error) (error, bool) { - if !errors.Is(err, sql.ErrConnDone) { - return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false - } - return nil, true - }, - }, - object: (*PrivateKeys)(nil), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertPrepare(t, tt.prepare, tt.object, tt.want.sqlExpectations, tt.want.err) - }) - } -} - -func fromBase16(base16 string) *big.Int { - i, ok := new(big.Int).SetString(base16, 16) - if !ok { - panic("bad number: " + base16) - } - return i -} - -const pubKey = `-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs38btwb3c7r0tMaQpGvB -mY+mPwMU/LpfuPoC0k2t4RsKp0fv40SMl50CRrHgk395wch8PMPYbl3+8TtYAJuy -rFALIj3Ff1UcKIk0hOH5DDsfh7/q2wFuncTmS6bifYo8CfSq2vDGnM7nZnEvxY/M -fSydZdcmIqlkUpfQmtzExw9+tSe5Dxq6gn5JtlGgLgZGt69r5iMMrTEGhhVAXzNu -MZbmlCoBru+rC8ITlTX/0V1ZcsSbL8tYWhthyu9x6yjo1bH85wiVI4gs0MhU8f2a -+kjL/KGZbR14Ua2eo6tonBZLC5DHWM2TkYXgRCDPufjcgmzN0Lm91E4P8KvBcvly -6QIDAQAB ------END PUBLIC KEY----- -` - -func TestQueries_GetPublicKeyByID(t *testing.T) { - now := time.Now() - future := now.Add(time.Hour) - - tests := []struct { - name string - eventstore func(*testing.T) *eventstore.Eventstore - encryption func(*testing.T) *crypto.MockEncryptionAlgorithm - want *rsaPublicKey - wantErr error - }{ - { - name: "filter error", - eventstore: expectEventstore( - expectFilterError(io.ErrClosedPipe), - ), - wantErr: io.ErrClosedPipe, - }, - { - name: "not found error", - eventstore: expectEventstore( - expectFilter(), - ), - wantErr: zerrors.ThrowNotFound(nil, "QUERY-Ahf7x", "Errors.Key.NotFound"), - }, - { - name: "decrypt error", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher(key_repo.NewAddedEvent(context.Background(), - &eventstore.Aggregate{ - ID: "keyID", - Type: key_repo.AggregateType, - ResourceOwner: "instanceID", - InstanceID: "instanceID", - Version: key_repo.AggregateVersion, - }, - crypto.KeyUsageSigning, "alg", - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("private"), - }, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("public"), - }, - future, - future, - )), - ), - ), - encryption: func(t *testing.T) *crypto.MockEncryptionAlgorithm { - encryption := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) - expect := encryption.EXPECT() - expect.Algorithm().Return("alg") - expect.DecryptionKeyIDs().Return([]string{}) - return encryption - }, - wantErr: zerrors.ThrowInternal(nil, "QUERY-Ie4oh", "Errors.Internal"), - }, - { - name: "parse error", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher(key_repo.NewAddedEvent(context.Background(), - &eventstore.Aggregate{ - ID: "keyID", - Type: key_repo.AggregateType, - ResourceOwner: "instanceID", - InstanceID: "instanceID", - Version: key_repo.AggregateVersion, - }, - crypto.KeyUsageSigning, "alg", - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("private"), - }, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("public"), - }, - future, - future, - )), - ), - ), - encryption: func(t *testing.T) *crypto.MockEncryptionAlgorithm { - encryption := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) - expect := encryption.EXPECT() - expect.Algorithm().Return("alg") - expect.DecryptionKeyIDs().Return([]string{"keyID"}) - expect.Decrypt([]byte("public"), "keyID").Return([]byte("foo"), nil) - return encryption - }, - wantErr: zerrors.ThrowInternal(nil, "QUERY-Kai2Z", "Errors.Internal"), - }, - { - name: "success", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher(key_repo.NewAddedEvent(context.Background(), - &eventstore.Aggregate{ - ID: "keyID", - Type: key_repo.AggregateType, - ResourceOwner: "instanceID", - InstanceID: "instanceID", - Version: key_repo.AggregateVersion, - }, - crypto.KeyUsageSigning, "alg", - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("private"), - }, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("public"), - }, - future, - future, - )), - ), - ), - encryption: func(t *testing.T) *crypto.MockEncryptionAlgorithm { - encryption := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) - expect := encryption.EXPECT() - expect.Algorithm().Return("alg") - expect.DecryptionKeyIDs().Return([]string{"keyID"}) - expect.Decrypt([]byte("public"), "keyID").Return([]byte(pubKey), nil) - return encryption - }, - want: &rsaPublicKey{ - key: key{ - id: "keyID", - resourceOwner: "instanceID", - algorithm: "alg", - use: crypto.KeyUsageSigning, - }, - expiry: future, - publicKey: func() *rsa.PublicKey { - publicKey, err := crypto.BytesToPublicKey([]byte(pubKey)) - if err != nil { - panic(err) - } - return publicKey - }(), - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - q := &Queries{ - eventstore: tt.eventstore(t), - } - if tt.encryption != nil { - q.keyEncryptionAlgorithm = tt.encryption(t) - } - ctx := authz.NewMockContext("instanceID", "orgID", "loginClient") - key, err := q.GetPublicKeyByID(ctx, "keyID") - if tt.wantErr != nil { - require.ErrorIs(t, err, tt.wantErr) - return - } - require.NoError(t, err) - require.NotNil(t, key) - - got := key.(*rsaPublicKey) - assert.WithinDuration(t, tt.want.expiry, got.expiry, time.Second) - tt.want.expiry = time.Time{} - got.expiry = time.Time{} - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go index 443353c2e5..3c33ff6fdf 100644 --- a/internal/query/projection/instance_features.go +++ b/internal/query/projection/instance_features.go @@ -80,10 +80,6 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.InstanceImprovedPerformanceEventType, Reduce: reduceInstanceSetFeature[[]feature.ImprovedPerformanceType], }, - { - Event: feature_v2.InstanceWebKeyEventType, - Reduce: reduceInstanceSetFeature[bool], - }, { Event: feature_v2.InstanceDebugOIDCParentErrorEventType, Reduce: reduceInstanceSetFeature[bool], diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go index 62fa568fca..25d0f270f6 100644 --- a/internal/repository/feature/feature_v2/eventstore.go +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -24,7 +24,6 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, InstanceUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) - eventstore.RegisterFilterEventMapper(AggregateType, InstanceWebKeyEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceDebugOIDCParentErrorEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceOIDCSingleV1SessionTerminationEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceDisableUserTokenEvent, eventstore.GenericEventMapper[SetEvent[bool]]) diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go index f75fae618b..a87042d72a 100644 --- a/internal/repository/feature/feature_v2/feature.go +++ b/internal/repository/feature/feature_v2/feature.go @@ -29,7 +29,6 @@ var ( InstanceUserSchemaEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyUserSchema) InstanceTokenExchangeEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTokenExchange) InstanceImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyImprovedPerformance) - InstanceWebKeyEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyWebKey) InstanceDebugOIDCParentErrorEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDebugOIDCParentError) InstanceOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyOIDCSingleV1SessionTermination) InstanceDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDisableUserTokenEvent) diff --git a/proto/zitadel/feature/v2/instance.proto b/proto/zitadel/feature/v2/instance.proto index 0455befb46..efbe5e3cdf 100644 --- a/proto/zitadel/feature/v2/instance.proto +++ b/proto/zitadel/feature/v2/instance.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2;feature"; message SetInstanceFeaturesRequest{ - reserved 3, 6; - reserved "oidc_legacy_introspection", "actions"; + reserved 3, 6, 8; + reserved "oidc_legacy_introspection", "actions", "web_key"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -49,13 +49,6 @@ message SetInstanceFeaturesRequest{ } ]; - optional bool web_key = 8 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; - } - ]; - optional bool debug_oidc_parent_error = 9 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -125,8 +118,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { - reserved 4, 7; - reserved "oidc_legacy_introspection", "actions"; + reserved 4, 7, 9; + reserved "oidc_legacy_introspection", "actions", "web_key"; zitadel.object.v2.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -163,13 +156,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag web_key = 9 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; - } - ]; - FeatureFlag debug_oidc_parent_error = 10 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2beta/instance.proto b/proto/zitadel/feature/v2beta/instance.proto index 8028305fe4..7968668e50 100644 --- a/proto/zitadel/feature/v2beta/instance.proto +++ b/proto/zitadel/feature/v2beta/instance.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2beta/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; message SetInstanceFeaturesRequest{ - reserved 3, 6; - reserved "oidc_legacy_introspection", "actions"; + reserved 3, 6, 8; + reserved "oidc_legacy_introspection", "actions", "web_key"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -49,13 +49,6 @@ message SetInstanceFeaturesRequest{ } ]; - optional bool web_key = 8 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; - } - ]; - optional bool debug_oidc_parent_error = 9 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -91,8 +84,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { - reserved 4, 7; - reserved "oidc_legacy_introspection", "actions"; + reserved 4, 7, 9; + reserved "oidc_legacy_introspection", "actions", "web_key"; zitadel.object.v2beta.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -129,13 +122,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag web_key = 9 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; - } - ]; - FeatureFlag debug_oidc_parent_error = 10 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; From 2691dae2b6355d3e962be791db28b88d4b763f98 Mon Sep 17 00:00:00 2001 From: "Marco A." Date: Fri, 27 Jun 2025 17:25:44 +0200 Subject: [PATCH 10/66] feat: App API v2 (#10077) # Which Problems Are Solved This PR *partially* addresses #9450 . Specifically, it implements the resource based API for the apps. APIs for app keys ARE not part of this PR. # How the Problems Are Solved - `CreateApplication`, `PatchApplication` (update) and `RegenerateClientSecret` endpoints are now unique for all app types: API, SAML and OIDC apps. - All new endpoints have integration tests - All new endpoints are using permission checks V2 # Additional Changes - The `ListApplications` endpoint allows to do sorting (see protobuf for details) and filtering by app type (see protobuf). - SAML and OIDC update endpoint can now receive requests for partial updates # Additional Context Partially addresses #9450 --- cmd/start/start.go | 5 + internal/api/grpc/admin/export.go | 2 +- internal/api/grpc/app/v2beta/app.go | 208 +++ .../api/grpc/app/v2beta/convert/api_app.go | 60 + .../grpc/app/v2beta/convert/api_app_test.go | 149 ++ .../api/grpc/app/v2beta/convert/convert.go | 165 ++ .../grpc/app/v2beta/convert/convert_test.go | 520 ++++++ .../api/grpc/app/v2beta/convert/oidc_app.go | 291 ++++ .../grpc/app/v2beta/convert/oidc_app_test.go | 755 +++++++++ .../api/grpc/app/v2beta/convert/saml_app.go | 77 + .../grpc/app/v2beta/convert/saml_app_test.go | 256 +++ .../app/v2beta/integration_test/app_test.go | 1446 +++++++++++++++++ .../app/v2beta/integration_test/query_test.go | 575 +++++++ .../v2beta/integration_test/server_test.go | 205 +++ internal/api/grpc/app/v2beta/query.go | 37 + internal/api/grpc/app/v2beta/server.go | 57 + internal/api/grpc/filter/v2/converter.go | 23 + .../grpc/management/project_application.go | 10 +- .../project_application_converter.go | 64 +- .../eventsourcing/view/application.go | 2 +- internal/command/permission_checks.go | 8 + internal/command/project_application.go | 28 +- internal/command/project_application_api.go | 37 +- .../command/project_application_api_test.go | 21 +- internal/command/project_application_oidc.go | 71 +- .../command/project_application_oidc_model.go | 82 +- .../command/project_application_oidc_test.go | 279 ++-- internal/command/project_application_saml.go | 40 +- .../command/project_application_saml_model.go | 22 +- .../command/project_application_saml_test.go | 161 +- internal/command/project_application_test.go | 133 +- internal/command/project_converter.go | 43 +- internal/command/project_model.go | 12 +- internal/domain/application_oidc.go | 93 +- internal/domain/application_oidc_test.go | 57 +- internal/domain/application_saml.go | 13 +- internal/domain/permission.go | 3 + internal/integration/client.go | 3 + internal/project/model/oidc_config.go | 4 +- internal/query/app.go | 92 +- pkg/grpc/app/v2beta/application.go | 5 + proto/zitadel/app/v2beta/api.proto | 26 + proto/zitadel/app/v2beta/app.proto | 94 ++ proto/zitadel/app/v2beta/app_service.proto | 788 +++++++++ proto/zitadel/app/v2beta/login.proto | 18 + proto/zitadel/app/v2beta/oidc.proto | 166 ++ proto/zitadel/app/v2beta/saml.proto | 20 + proto/zitadel/management.proto | 222 +-- 48 files changed, 6845 insertions(+), 603 deletions(-) create mode 100644 internal/api/grpc/app/v2beta/app.go create mode 100644 internal/api/grpc/app/v2beta/convert/api_app.go create mode 100644 internal/api/grpc/app/v2beta/convert/api_app_test.go create mode 100644 internal/api/grpc/app/v2beta/convert/convert.go create mode 100644 internal/api/grpc/app/v2beta/convert/convert_test.go create mode 100644 internal/api/grpc/app/v2beta/convert/oidc_app.go create mode 100644 internal/api/grpc/app/v2beta/convert/oidc_app_test.go create mode 100644 internal/api/grpc/app/v2beta/convert/saml_app.go create mode 100644 internal/api/grpc/app/v2beta/convert/saml_app_test.go create mode 100644 internal/api/grpc/app/v2beta/integration_test/app_test.go create mode 100644 internal/api/grpc/app/v2beta/integration_test/query_test.go create mode 100644 internal/api/grpc/app/v2beta/integration_test/server_test.go create mode 100644 internal/api/grpc/app/v2beta/query.go create mode 100644 internal/api/grpc/app/v2beta/server.go create mode 100644 pkg/grpc/app/v2beta/application.go create mode 100644 proto/zitadel/app/v2beta/api.proto create mode 100644 proto/zitadel/app/v2beta/app.proto create mode 100644 proto/zitadel/app/v2beta/app_service.proto create mode 100644 proto/zitadel/app/v2beta/login.proto create mode 100644 proto/zitadel/app/v2beta/oidc.proto create mode 100644 proto/zitadel/app/v2beta/saml.proto diff --git a/cmd/start/start.go b/cmd/start/start.go index 3c3b5cb3e0..dbd6289041 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -36,6 +36,7 @@ import ( internal_authz "github.com/zitadel/zitadel/internal/api/authz" action_v2_beta "github.com/zitadel/zitadel/internal/api/grpc/action/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/admin" + app "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/auth" feature_v2 "github.com/zitadel/zitadel/internal/api/grpc/feature/v2" feature_v2beta "github.com/zitadel/zitadel/internal/api/grpc/feature/v2beta" @@ -509,6 +510,10 @@ func startAPIs( if err := apis.RegisterService(ctx, debug_events.CreateServer(commands, queries)); err != nil { return nil, err } + if err := apis.RegisterService(ctx, app.CreateServer(commands, queries, permissionCheck)); err != nil { + return nil, err + } + instanceInterceptor := middleware.InstanceInterceptor(queries, config.ExternalDomain, login.IgnoreInstanceEndpoints...) assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge) apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.SystemAuthZ, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle)) diff --git a/internal/api/grpc/admin/export.go b/internal/api/grpc/admin/export.go index b5d36272d4..8024cd9d6e 100644 --- a/internal/api/grpc/admin/export.go +++ b/internal/api/grpc/admin/export.go @@ -783,7 +783,7 @@ func (s *Server) getProjectsAndApps(ctx context.Context, org string) ([]*v1_pb.D if err != nil { return nil, nil, nil, nil, nil, err } - apps, err := s.query.SearchApps(ctx, &query.AppSearchQueries{Queries: []query.SearchQuery{appSearch}}, false) + apps, err := s.query.SearchApps(ctx, &query.AppSearchQueries{Queries: []query.SearchQuery{appSearch}}, nil) if err != nil { return nil, nil, nil, nil, nil, err } diff --git a/internal/api/grpc/app/v2beta/app.go b/internal/api/grpc/app/v2beta/app.go new file mode 100644 index 0000000000..48c602f454 --- /dev/null +++ b/internal/api/grpc/app/v2beta/app.go @@ -0,0 +1,208 @@ +package app + +import ( + "context" + "strings" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func (s *Server) CreateApplication(ctx context.Context, req *app.CreateApplicationRequest) (*app.CreateApplicationResponse, error) { + switch t := req.GetCreationRequestType().(type) { + case *app.CreateApplicationRequest_ApiRequest: + apiApp, err := s.command.AddAPIApplication(ctx, convert.CreateAPIApplicationRequestToDomain(req.GetName(), req.GetProjectId(), req.GetId(), t.ApiRequest), "") + if err != nil { + return nil, err + } + + return &app.CreateApplicationResponse{ + AppId: apiApp.AppID, + CreationDate: timestamppb.New(apiApp.ChangeDate), + CreationResponseType: &app.CreateApplicationResponse_ApiResponse{ + ApiResponse: &app.CreateAPIApplicationResponse{ + ClientId: apiApp.ClientID, + ClientSecret: apiApp.ClientSecretString, + }, + }, + }, nil + + case *app.CreateApplicationRequest_OidcRequest: + oidcAppRequest, err := convert.CreateOIDCAppRequestToDomain(req.GetName(), req.GetProjectId(), req.GetOidcRequest()) + if err != nil { + return nil, err + } + + oidcApp, err := s.command.AddOIDCApplication(ctx, oidcAppRequest, "") + if err != nil { + return nil, err + } + + return &app.CreateApplicationResponse{ + AppId: oidcApp.AppID, + CreationDate: timestamppb.New(oidcApp.ChangeDate), + CreationResponseType: &app.CreateApplicationResponse_OidcResponse{ + OidcResponse: &app.CreateOIDCApplicationResponse{ + ClientId: oidcApp.ClientID, + ClientSecret: oidcApp.ClientSecretString, + NoneCompliant: oidcApp.Compliance.NoneCompliant, + ComplianceProblems: convert.ComplianceProblemsToLocalizedMessages(oidcApp.Compliance.Problems), + }, + }, + }, nil + + case *app.CreateApplicationRequest_SamlRequest: + samlAppRequest, err := convert.CreateSAMLAppRequestToDomain(req.GetName(), req.GetProjectId(), req.GetSamlRequest()) + if err != nil { + return nil, err + } + + samlApp, err := s.command.AddSAMLApplication(ctx, samlAppRequest, "") + if err != nil { + return nil, err + } + + return &app.CreateApplicationResponse{ + AppId: samlApp.AppID, + CreationDate: timestamppb.New(samlApp.ChangeDate), + CreationResponseType: &app.CreateApplicationResponse_SamlResponse{ + SamlResponse: &app.CreateSAMLApplicationResponse{}, + }, + }, nil + default: + return nil, zerrors.ThrowInvalidArgument(nil, "APP-0iiN46", "unknown app type") + } +} + +func (s *Server) UpdateApplication(ctx context.Context, req *app.UpdateApplicationRequest) (*app.UpdateApplicationResponse, error) { + var changedTime time.Time + + if name := strings.TrimSpace(req.GetName()); name != "" { + updatedDetails, err := s.command.UpdateApplicationName( + ctx, + req.GetProjectId(), + &domain.ChangeApp{ + AppID: req.GetId(), + AppName: name, + }, + "", + ) + if err != nil { + return nil, err + } + + changedTime = updatedDetails.EventDate + } + + switch t := req.GetUpdateRequestType().(type) { + case *app.UpdateApplicationRequest_ApiConfigurationRequest: + updatedAPIApp, err := s.command.UpdateAPIApplication(ctx, convert.UpdateAPIApplicationConfigurationRequestToDomain(req.GetId(), req.GetProjectId(), t.ApiConfigurationRequest), "") + if err != nil { + return nil, err + } + + changedTime = updatedAPIApp.ChangeDate + + case *app.UpdateApplicationRequest_OidcConfigurationRequest: + oidcApp, err := convert.UpdateOIDCAppConfigRequestToDomain(req.GetId(), req.GetProjectId(), t.OidcConfigurationRequest) + if err != nil { + return nil, err + } + + updatedOIDCApp, err := s.command.UpdateOIDCApplication(ctx, oidcApp, "") + if err != nil { + return nil, err + } + + changedTime = updatedOIDCApp.ChangeDate + + case *app.UpdateApplicationRequest_SamlConfigurationRequest: + samlApp, err := convert.UpdateSAMLAppConfigRequestToDomain(req.GetId(), req.GetProjectId(), t.SamlConfigurationRequest) + if err != nil { + return nil, err + } + + updatedSAMLApp, err := s.command.UpdateSAMLApplication(ctx, samlApp, "") + if err != nil { + return nil, err + } + + changedTime = updatedSAMLApp.ChangeDate + } + + return &app.UpdateApplicationResponse{ + ChangeDate: timestamppb.New(changedTime), + }, nil +} + +func (s *Server) DeleteApplication(ctx context.Context, req *app.DeleteApplicationRequest) (*app.DeleteApplicationResponse, error) { + details, err := s.command.RemoveApplication(ctx, req.GetProjectId(), req.GetId(), "") + if err != nil { + return nil, err + } + + return &app.DeleteApplicationResponse{ + DeletionDate: timestamppb.New(details.EventDate), + }, nil +} + +func (s *Server) DeactivateApplication(ctx context.Context, req *app.DeactivateApplicationRequest) (*app.DeactivateApplicationResponse, error) { + details, err := s.command.DeactivateApplication(ctx, req.GetProjectId(), req.GetId(), "") + if err != nil { + return nil, err + } + + return &app.DeactivateApplicationResponse{ + DeactivationDate: timestamppb.New(details.EventDate), + }, nil + +} + +func (s *Server) ReactivateApplication(ctx context.Context, req *app.ReactivateApplicationRequest) (*app.ReactivateApplicationResponse, error) { + details, err := s.command.ReactivateApplication(ctx, req.GetProjectId(), req.GetId(), "") + if err != nil { + return nil, err + } + + return &app.ReactivateApplicationResponse{ + ReactivationDate: timestamppb.New(details.EventDate), + }, nil + +} + +func (s *Server) RegenerateClientSecret(ctx context.Context, req *app.RegenerateClientSecretRequest) (*app.RegenerateClientSecretResponse, error) { + var secret string + var changeDate time.Time + + switch req.GetAppType().(type) { + case *app.RegenerateClientSecretRequest_IsApi: + config, err := s.command.ChangeAPIApplicationSecret(ctx, req.GetProjectId(), req.GetApplicationId(), "") + if err != nil { + return nil, err + } + secret = config.ClientSecretString + changeDate = config.ChangeDate + + case *app.RegenerateClientSecretRequest_IsOidc: + config, err := s.command.ChangeOIDCApplicationSecret(ctx, req.GetProjectId(), req.GetApplicationId(), "") + if err != nil { + return nil, err + } + + secret = config.ClientSecretString + changeDate = config.ChangeDate + + default: + return nil, zerrors.ThrowInvalidArgument(nil, "APP-aLWIzw", "unknown app type") + } + + return &app.RegenerateClientSecretResponse{ + ClientSecret: secret, + CreationDate: timestamppb.New(changeDate), + }, nil +} diff --git a/internal/api/grpc/app/v2beta/convert/api_app.go b/internal/api/grpc/app/v2beta/convert/api_app.go new file mode 100644 index 0000000000..bad76ab0d5 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/api_app.go @@ -0,0 +1,60 @@ +package convert + +import ( + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func CreateAPIApplicationRequestToDomain(name, projectID, appID string, app *app.CreateAPIApplicationRequest) *domain.APIApp { + return &domain.APIApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppName: name, + AppID: appID, + AuthMethodType: apiAuthMethodTypeToDomain(app.GetAuthMethodType()), + } +} + +func UpdateAPIApplicationConfigurationRequestToDomain(appID, projectID string, app *app.UpdateAPIApplicationConfigurationRequest) *domain.APIApp { + return &domain.APIApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppID: appID, + AuthMethodType: apiAuthMethodTypeToDomain(app.GetAuthMethodType()), + } +} + +func appAPIConfigToPb(apiApp *query.APIApp) app.ApplicationConfig { + return &app.Application_ApiConfig{ + ApiConfig: &app.APIConfig{ + ClientId: apiApp.ClientID, + AuthMethodType: apiAuthMethodTypeToPb(apiApp.AuthMethodType), + }, + } +} + +func apiAuthMethodTypeToDomain(authType app.APIAuthMethodType) domain.APIAuthMethodType { + switch authType { + case app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC: + return domain.APIAuthMethodTypeBasic + case app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT: + return domain.APIAuthMethodTypePrivateKeyJWT + default: + return domain.APIAuthMethodTypeBasic + } +} + +func apiAuthMethodTypeToPb(methodType domain.APIAuthMethodType) app.APIAuthMethodType { + switch methodType { + case domain.APIAuthMethodTypeBasic: + return app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC + case domain.APIAuthMethodTypePrivateKeyJWT: + return app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT + default: + return app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC + } +} diff --git a/internal/api/grpc/app/v2beta/convert/api_app_test.go b/internal/api/grpc/app/v2beta/convert/api_app_test.go new file mode 100644 index 0000000000..9f15c3df76 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/api_app_test.go @@ -0,0 +1,149 @@ +package convert + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func TestCreateAPIApplicationRequestToDomain(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + appName string + projectID string + appID string + req *app.CreateAPIApplicationRequest + want *domain.APIApp + }{ + { + name: "basic auth method", + appName: "my-app", + projectID: "proj-1", + appID: "someID", + req: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppName: "my-app", + AuthMethodType: domain.APIAuthMethodTypeBasic, + AppID: "someID", + }, + }, + { + name: "private key jwt", + appName: "jwt-app", + projectID: "proj-2", + req: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-2"}, + AppName: "jwt-app", + AuthMethodType: domain.APIAuthMethodTypePrivateKeyJWT, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + got := CreateAPIApplicationRequestToDomain(tt.appName, tt.projectID, tt.appID, tt.req) + + // Then + assert.Equal(t, tt.want, got) + }) + } +} + +func TestUpdateAPIApplicationConfigurationRequestToDomain(t *testing.T) { + t.Parallel() + tests := []struct { + name string + appID string + projectID string + req *app.UpdateAPIApplicationConfigurationRequest + want *domain.APIApp + }{ + { + name: "basic auth method", + appID: "app-1", + projectID: "proj-1", + req: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppID: "app-1", + AuthMethodType: domain.APIAuthMethodTypeBasic, + }, + }, + { + name: "private key jwt", + appID: "app-2", + projectID: "proj-2", + req: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-2"}, + AppID: "app-2", + AuthMethodType: domain.APIAuthMethodTypePrivateKeyJWT, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + got := UpdateAPIApplicationConfigurationRequestToDomain(tt.appID, tt.projectID, tt.req) + + // Then + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_apiAuthMethodTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + methodType domain.APIAuthMethodType + expectedResult app.APIAuthMethodType + }{ + { + name: "basic auth method", + methodType: domain.APIAuthMethodTypeBasic, + expectedResult: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + { + name: "private key jwt", + methodType: domain.APIAuthMethodTypePrivateKeyJWT, + expectedResult: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + { + name: "unknown auth method defaults to basic", + expectedResult: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + res := apiAuthMethodTypeToPb(tc.methodType) + + // Then + assert.Equal(t, tc.expectedResult, res) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/convert/convert.go b/internal/api/grpc/app/v2beta/convert/convert.go new file mode 100644 index 0000000000..c732b3a0c5 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/convert.go @@ -0,0 +1,165 @@ +package convert + +import ( + "net/url" + + "github.com/muhlemmer/gu" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func AppToPb(query_app *query.App) *app.Application { + if query_app == nil { + return &app.Application{} + } + + return &app.Application{ + Id: query_app.ID, + CreationDate: timestamppb.New(query_app.CreationDate), + ChangeDate: timestamppb.New(query_app.ChangeDate), + State: appStateToPb(query_app.State), + Name: query_app.Name, + Config: appConfigToPb(query_app), + } +} + +func AppsToPb(queryApps []*query.App) []*app.Application { + pbApps := make([]*app.Application, len(queryApps)) + + for i, queryApp := range queryApps { + pbApps[i] = AppToPb(queryApp) + } + + return pbApps +} + +func ListApplicationsRequestToModel(sysDefaults systemdefaults.SystemDefaults, req *app.ListApplicationsRequest) (*query.AppSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(sysDefaults, req.GetPagination()) + if err != nil { + return nil, err + } + + queries, err := appQueriesToModel(req.GetFilters()) + if err != nil { + return nil, err + } + projectQuery, err := query.NewAppProjectIDSearchQuery(req.GetProjectId()) + if err != nil { + return nil, err + } + + queries = append(queries, projectQuery) + return &query.AppSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: appSortingToColumn(req.GetSortingColumn()), + }, + + Queries: queries, + }, nil +} + +func appSortingToColumn(sortingCriteria app.AppSorting) query.Column { + switch sortingCriteria { + case app.AppSorting_APP_SORT_BY_CHANGE_DATE: + return query.AppColumnChangeDate + case app.AppSorting_APP_SORT_BY_CREATION_DATE: + return query.AppColumnCreationDate + case app.AppSorting_APP_SORT_BY_NAME: + return query.AppColumnName + case app.AppSorting_APP_SORT_BY_STATE: + return query.AppColumnState + case app.AppSorting_APP_SORT_BY_ID: + fallthrough + default: + return query.AppColumnID + } +} + +func appStateToPb(state domain.AppState) app.AppState { + switch state { + case domain.AppStateActive: + return app.AppState_APP_STATE_ACTIVE + case domain.AppStateInactive: + return app.AppState_APP_STATE_INACTIVE + case domain.AppStateRemoved: + return app.AppState_APP_STATE_REMOVED + case domain.AppStateUnspecified: + fallthrough + default: + return app.AppState_APP_STATE_UNSPECIFIED + } +} + +func appConfigToPb(app *query.App) app.ApplicationConfig { + if app.OIDCConfig != nil { + return appOIDCConfigToPb(app.OIDCConfig) + } + if app.SAMLConfig != nil { + return appSAMLConfigToPb(app.SAMLConfig) + } + return appAPIConfigToPb(app.APIConfig) +} + +func loginVersionToDomain(version *app.LoginVersion) (*domain.LoginVersion, *string, error) { + switch v := version.GetVersion().(type) { + case nil: + return gu.Ptr(domain.LoginVersionUnspecified), gu.Ptr(""), nil + case *app.LoginVersion_LoginV1: + return gu.Ptr(domain.LoginVersion1), gu.Ptr(""), nil + case *app.LoginVersion_LoginV2: + _, err := url.Parse(v.LoginV2.GetBaseUri()) + return gu.Ptr(domain.LoginVersion2), gu.Ptr(v.LoginV2.GetBaseUri()), err + default: + return gu.Ptr(domain.LoginVersionUnspecified), gu.Ptr(""), nil + } +} + +func loginVersionToPb(version domain.LoginVersion, baseURI *string) *app.LoginVersion { + switch version { + case domain.LoginVersionUnspecified: + return nil + case domain.LoginVersion1: + return &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}} + case domain.LoginVersion2: + return &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: baseURI}}} + default: + return nil + } +} + +func appQueriesToModel(queries []*app.ApplicationSearchFilter) (toReturn []query.SearchQuery, err error) { + toReturn = make([]query.SearchQuery, len(queries)) + for i, query := range queries { + toReturn[i], err = appQueryToModel(query) + if err != nil { + return nil, err + } + } + return toReturn, nil +} + +func appQueryToModel(appQuery *app.ApplicationSearchFilter) (query.SearchQuery, error) { + switch q := appQuery.GetFilter().(type) { + case *app.ApplicationSearchFilter_NameFilter: + return query.NewAppNameSearchQuery(filter.TextMethodPbToQuery(q.NameFilter.GetMethod()), q.NameFilter.Name) + case *app.ApplicationSearchFilter_StateFilter: + return query.NewAppStateSearchQuery(domain.AppState(q.StateFilter)) + case *app.ApplicationSearchFilter_ApiAppOnly: + return query.NewNotNullQuery(query.AppAPIConfigColumnAppID) + case *app.ApplicationSearchFilter_OidcAppOnly: + return query.NewNotNullQuery(query.AppOIDCConfigColumnAppID) + case *app.ApplicationSearchFilter_SamlAppOnly: + return query.NewNotNullQuery(query.AppSAMLConfigColumnAppID) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "CONV-z2mAGy", "List.Query.Invalid") + } +} diff --git a/internal/api/grpc/app/v2beta/convert/convert_test.go b/internal/api/grpc/app/v2beta/convert/convert_test.go new file mode 100644 index 0000000000..5835691d43 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/convert_test.go @@ -0,0 +1,520 @@ +package convert + +import ( + "errors" + "fmt" + "net/url" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + filter_pb_v2 "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + filter_pb_v2_beta "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" +) + +func TestAppToPb(t *testing.T) { + t.Parallel() + + now := time.Now() + + tt := []struct { + testName string + inputQueryApp *query.App + expectedPbApp *app.Application + }{ + { + testName: "full app conversion", + inputQueryApp: &query.App{ + ID: "id", + CreationDate: now, + ChangeDate: now, + State: domain.AppStateActive, + Name: "test-app", + APIConfig: &query.APIApp{}, + }, + expectedPbApp: &app.Application{ + Id: "id", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + State: app.AppState_APP_STATE_ACTIVE, + Name: "test-app", + Config: &app.Application_ApiConfig{ + ApiConfig: &app.APIConfig{}, + }, + }, + }, + { + testName: "nil app", + inputQueryApp: nil, + expectedPbApp: &app.Application{}, + }, + } + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res := AppToPb(tc.inputQueryApp) + + // Then + assert.Equal(t, tc.expectedPbApp, res) + }) + } +} + +func TestListApplicationsRequestToModel(t *testing.T) { + t.Parallel() + + validSearchByNameQuery, err := query.NewAppNameSearchQuery(filter.TextMethodPbToQuery(filter_pb_v2_beta.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS), "test") + require.NoError(t, err) + + validSearchByProjectQuery, err := query.NewAppProjectIDSearchQuery("project1") + require.NoError(t, err) + + sysDefaults := systemdefaults.SystemDefaults{DefaultQueryLimit: 100, MaxQueryLimit: 150} + + tt := []struct { + testName string + req *app.ListApplicationsRequest + + expectedResponse *query.AppSearchQueries + expectedError error + }{ + { + testName: "invalid pagination limit", + req: &app.ListApplicationsRequest{ + Pagination: &filter_pb_v2.PaginationRequest{Asc: true, Limit: uint32(sysDefaults.MaxQueryLimit + 1)}, + }, + expectedResponse: nil, + expectedError: zerrors.ThrowInvalidArgumentf(fmt.Errorf("given: %d, allowed: %d", sysDefaults.MaxQueryLimit+1, sysDefaults.MaxQueryLimit), "QUERY-4M0fs", "Errors.Query.LimitExceeded"), + }, + { + testName: "empty request", + req: &app.ListApplicationsRequest{ + ProjectId: "project1", + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResponse: &query.AppSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AppColumnID, + }, + Queries: []query.SearchQuery{ + validSearchByProjectQuery, + }, + }, + }, + { + testName: "valid request", + req: &app.ListApplicationsRequest{ + ProjectId: "project1", + Filters: []*app.ApplicationSearchFilter{ + { + Filter: &app.ApplicationSearchFilter_NameFilter{NameFilter: &app.ApplicationNameQuery{Name: "test"}}, + }, + }, + SortingColumn: app.AppSorting_APP_SORT_BY_NAME, + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + + expectedResponse: &query.AppSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AppColumnName, + }, + Queries: []query.SearchQuery{ + validSearchByNameQuery, + validSearchByProjectQuery, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + got, err := ListApplicationsRequestToModel(sysDefaults, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResponse, got) + }) + } +} + +func TestAppSortingToColumn(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + sorting app.AppSorting + expected query.Column + }{ + { + name: "sort by change date", + sorting: app.AppSorting_APP_SORT_BY_CHANGE_DATE, + expected: query.AppColumnChangeDate, + }, + { + name: "sort by creation date", + sorting: app.AppSorting_APP_SORT_BY_CREATION_DATE, + expected: query.AppColumnCreationDate, + }, + { + name: "sort by name", + sorting: app.AppSorting_APP_SORT_BY_NAME, + expected: query.AppColumnName, + }, + { + name: "sort by state", + sorting: app.AppSorting_APP_SORT_BY_STATE, + expected: query.AppColumnState, + }, + { + name: "sort by ID", + sorting: app.AppSorting_APP_SORT_BY_ID, + expected: query.AppColumnID, + }, + { + name: "unknown sorting defaults to ID", + sorting: app.AppSorting(99), + expected: query.AppColumnID, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := appSortingToColumn(tc.sorting) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestAppStateToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + state domain.AppState + expected app.AppState + }{ + { + name: "active state", + state: domain.AppStateActive, + expected: app.AppState_APP_STATE_ACTIVE, + }, + { + name: "inactive state", + state: domain.AppStateInactive, + expected: app.AppState_APP_STATE_INACTIVE, + }, + { + name: "removed state", + state: domain.AppStateRemoved, + expected: app.AppState_APP_STATE_REMOVED, + }, + { + name: "unspecified state", + state: domain.AppStateUnspecified, + expected: app.AppState_APP_STATE_UNSPECIFIED, + }, + { + name: "unknown state defaults to unspecified", + state: domain.AppState(99), + expected: app.AppState_APP_STATE_UNSPECIFIED, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := appStateToPb(tc.state) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestAppConfigToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + app *query.App + expected app.ApplicationConfig + }{ + { + name: "OIDC config", + app: &query.App{ + OIDCConfig: &query.OIDCApp{}, + }, + expected: &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + ResponseTypes: []app.OIDCResponseType{}, + GrantTypes: []app.OIDCGrantType{}, + ComplianceProblems: []*app.OIDCLocalizedMessage{}, + ClockSkew: &durationpb.Duration{}, + }, + }, + }, + { + name: "SAML config", + app: &query.App{ + SAMLConfig: &query.SAMLApp{}, + }, + expected: &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{}, + }, + }, + }, + { + name: "API config", + app: &query.App{ + APIConfig: &query.APIApp{}, + }, + expected: &app.Application_ApiConfig{ + ApiConfig: &app.APIConfig{}, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := appConfigToPb(tc.app) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestLoginVersionToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + version *app.LoginVersion + expectedVer *domain.LoginVersion + expectedURI *string + expectedError error + }{ + { + name: "nil version", + version: nil, + expectedVer: gu.Ptr(domain.LoginVersionUnspecified), + expectedURI: gu.Ptr(""), + }, + { + name: "login v1", + version: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + expectedVer: gu.Ptr(domain.LoginVersion1), + expectedURI: gu.Ptr(""), + }, + { + name: "login v2 valid URI", + version: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: gu.Ptr("https://valid.url")}}}, + expectedVer: gu.Ptr(domain.LoginVersion2), + expectedURI: gu.Ptr("https://valid.url"), + }, + { + name: "login v2 invalid URI", + version: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: gu.Ptr("://invalid")}}}, + expectedVer: gu.Ptr(domain.LoginVersion2), + expectedURI: gu.Ptr("://invalid"), + expectedError: &url.Error{Op: "parse", URL: "://invalid", Err: errors.New("missing protocol scheme")}, + }, + { + name: "unknown version type", + version: &app.LoginVersion{}, + expectedVer: gu.Ptr(domain.LoginVersionUnspecified), + expectedURI: gu.Ptr(""), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + version, uri, err := loginVersionToDomain(tc.version) + + // Then + assert.Equal(t, tc.expectedVer, version) + assert.Equal(t, tc.expectedURI, uri) + assert.Equal(t, tc.expectedError, err) + }) + } +} + +func TestLoginVersionToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + version domain.LoginVersion + baseURI *string + expected *app.LoginVersion + }{ + { + name: "unspecified version", + version: domain.LoginVersionUnspecified, + baseURI: nil, + expected: nil, + }, + { + name: "login v1", + version: domain.LoginVersion1, + baseURI: nil, + expected: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV1{ + LoginV1: &app.LoginV1{}, + }, + }, + }, + { + name: "login v2", + version: domain.LoginVersion2, + baseURI: gu.Ptr("https://example.com"), + expected: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: gu.Ptr("https://example.com"), + }, + }, + }, + }, + { + name: "unknown version", + version: domain.LoginVersion(99), + baseURI: nil, + expected: nil, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := loginVersionToPb(tc.version, tc.baseURI) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestAppQueryToModel(t *testing.T) { + t.Parallel() + + validAppNameSearchQuery, err := query.NewAppNameSearchQuery(query.TextEquals, "test") + require.NoError(t, err) + + validAppStateSearchQuery, err := query.NewAppStateSearchQuery(domain.AppStateActive) + require.NoError(t, err) + + tt := []struct { + name string + query *app.ApplicationSearchFilter + + expectedQuery query.SearchQuery + expectedError error + }{ + { + name: "name query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_NameFilter{ + NameFilter: &app.ApplicationNameQuery{ + Name: "test", + Method: filter_pb_v2.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, + }, + }, + }, + expectedQuery: validAppNameSearchQuery, + }, + { + name: "state query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_StateFilter{ + StateFilter: app.AppState_APP_STATE_ACTIVE, + }, + }, + expectedQuery: validAppStateSearchQuery, + }, + { + name: "api app only query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_ApiAppOnly{}, + }, + expectedQuery: &query.NotNullQuery{ + Column: query.AppAPIConfigColumnAppID, + }, + }, + { + name: "oidc app only query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_OidcAppOnly{}, + }, + expectedQuery: &query.NotNullQuery{ + Column: query.AppOIDCConfigColumnAppID, + }, + }, + { + name: "saml app only query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_SamlAppOnly{}, + }, + expectedQuery: &query.NotNullQuery{ + Column: query.AppSAMLConfigColumnAppID, + }, + }, + { + name: "invalid query type", + query: &app.ApplicationSearchFilter{}, + expectedQuery: nil, + expectedError: zerrors.ThrowInvalidArgument(nil, "CONV-z2mAGy", "List.Query.Invalid"), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result, err := appQueryToModel(tc.query) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedQuery, result) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/convert/oidc_app.go b/internal/api/grpc/app/v2beta/convert/oidc_app.go new file mode 100644 index 0000000000..223e43d166 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/oidc_app.go @@ -0,0 +1,291 @@ +package convert + +import ( + "github.com/muhlemmer/gu" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func CreateOIDCAppRequestToDomain(name, projectID string, req *app.CreateOIDCApplicationRequest) (*domain.OIDCApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(req.GetLoginVersion()) + if err != nil { + return nil, err + } + return &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppName: name, + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: req.GetRedirectUris(), + ResponseTypes: oidcResponseTypesToDomain(req.GetResponseTypes()), + GrantTypes: oidcGrantTypesToDomain(req.GetGrantTypes()), + ApplicationType: gu.Ptr(oidcApplicationTypeToDomain(req.GetAppType())), + AuthMethodType: gu.Ptr(oidcAuthMethodTypeToDomain(req.GetAuthMethodType())), + PostLogoutRedirectUris: req.GetPostLogoutRedirectUris(), + DevMode: &req.DevMode, + AccessTokenType: gu.Ptr(oidcTokenTypeToDomain(req.GetAccessTokenType())), + AccessTokenRoleAssertion: gu.Ptr(req.GetAccessTokenRoleAssertion()), + IDTokenRoleAssertion: gu.Ptr(req.GetIdTokenRoleAssertion()), + IDTokenUserinfoAssertion: gu.Ptr(req.GetIdTokenUserinfoAssertion()), + ClockSkew: gu.Ptr(req.GetClockSkew().AsDuration()), + AdditionalOrigins: req.GetAdditionalOrigins(), + SkipNativeAppSuccessPage: gu.Ptr(req.GetSkipNativeAppSuccessPage()), + BackChannelLogoutURI: gu.Ptr(req.GetBackChannelLogoutUri()), + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func UpdateOIDCAppConfigRequestToDomain(appID, projectID string, app *app.UpdateOIDCApplicationConfigurationRequest) (*domain.OIDCApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(app.GetLoginVersion()) + if err != nil { + return nil, err + } + return &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppID: appID, + RedirectUris: app.RedirectUris, + ResponseTypes: oidcResponseTypesToDomain(app.ResponseTypes), + GrantTypes: oidcGrantTypesToDomain(app.GrantTypes), + ApplicationType: oidcApplicationTypeToDomainPtr(app.AppType), + AuthMethodType: oidcAuthMethodTypeToDomainPtr(app.AuthMethodType), + PostLogoutRedirectUris: app.PostLogoutRedirectUris, + DevMode: app.DevMode, + AccessTokenType: oidcTokenTypeToDomainPtr(app.AccessTokenType), + AccessTokenRoleAssertion: app.AccessTokenRoleAssertion, + IDTokenRoleAssertion: app.IdTokenRoleAssertion, + IDTokenUserinfoAssertion: app.IdTokenUserinfoAssertion, + ClockSkew: gu.Ptr(app.GetClockSkew().AsDuration()), + AdditionalOrigins: app.AdditionalOrigins, + SkipNativeAppSuccessPage: app.SkipNativeAppSuccessPage, + BackChannelLogoutURI: app.BackChannelLogoutUri, + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func oidcResponseTypesToDomain(responseTypes []app.OIDCResponseType) []domain.OIDCResponseType { + if len(responseTypes) == 0 { + return []domain.OIDCResponseType{domain.OIDCResponseTypeCode} + } + oidcResponseTypes := make([]domain.OIDCResponseType, len(responseTypes)) + for i, responseType := range responseTypes { + switch responseType { + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED: + oidcResponseTypes[i] = domain.OIDCResponseTypeUnspecified + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE: + oidcResponseTypes[i] = domain.OIDCResponseTypeCode + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN: + oidcResponseTypes[i] = domain.OIDCResponseTypeIDToken + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN: + oidcResponseTypes[i] = domain.OIDCResponseTypeIDTokenToken + } + } + return oidcResponseTypes +} + +func oidcGrantTypesToDomain(grantTypes []app.OIDCGrantType) []domain.OIDCGrantType { + if len(grantTypes) == 0 { + return []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode} + } + oidcGrantTypes := make([]domain.OIDCGrantType, len(grantTypes)) + for i, grantType := range grantTypes { + switch grantType { + case app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE: + oidcGrantTypes[i] = domain.OIDCGrantTypeAuthorizationCode + case app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT: + oidcGrantTypes[i] = domain.OIDCGrantTypeImplicit + case app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN: + oidcGrantTypes[i] = domain.OIDCGrantTypeRefreshToken + case app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE: + oidcGrantTypes[i] = domain.OIDCGrantTypeDeviceCode + case app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE: + oidcGrantTypes[i] = domain.OIDCGrantTypeTokenExchange + } + } + return oidcGrantTypes +} + +func oidcApplicationTypeToDomainPtr(appType *app.OIDCAppType) *domain.OIDCApplicationType { + if appType == nil { + return nil + } + + res := oidcApplicationTypeToDomain(*appType) + return &res +} + +func oidcApplicationTypeToDomain(appType app.OIDCAppType) domain.OIDCApplicationType { + switch appType { + case app.OIDCAppType_OIDC_APP_TYPE_WEB: + return domain.OIDCApplicationTypeWeb + case app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT: + return domain.OIDCApplicationTypeUserAgent + case app.OIDCAppType_OIDC_APP_TYPE_NATIVE: + return domain.OIDCApplicationTypeNative + } + return domain.OIDCApplicationTypeWeb +} + +func oidcAuthMethodTypeToDomainPtr(authType *app.OIDCAuthMethodType) *domain.OIDCAuthMethodType { + if authType == nil { + return nil + } + + res := oidcAuthMethodTypeToDomain(*authType) + return &res +} + +func oidcAuthMethodTypeToDomain(authType app.OIDCAuthMethodType) domain.OIDCAuthMethodType { + switch authType { + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC: + return domain.OIDCAuthMethodTypeBasic + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST: + return domain.OIDCAuthMethodTypePost + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE: + return domain.OIDCAuthMethodTypeNone + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT: + return domain.OIDCAuthMethodTypePrivateKeyJWT + default: + return domain.OIDCAuthMethodTypeBasic + } +} + +func oidcTokenTypeToDomainPtr(tokenType *app.OIDCTokenType) *domain.OIDCTokenType { + if tokenType == nil { + return nil + } + + res := oidcTokenTypeToDomain(*tokenType) + return &res +} + +func oidcTokenTypeToDomain(tokenType app.OIDCTokenType) domain.OIDCTokenType { + switch tokenType { + case app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER: + return domain.OIDCTokenTypeBearer + case app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT: + return domain.OIDCTokenTypeJWT + default: + return domain.OIDCTokenTypeBearer + } +} + +func ComplianceProblemsToLocalizedMessages(complianceProblems []string) []*app.OIDCLocalizedMessage { + converted := make([]*app.OIDCLocalizedMessage, len(complianceProblems)) + for i, p := range complianceProblems { + converted[i] = &app.OIDCLocalizedMessage{Key: p} + } + + return converted +} + +func appOIDCConfigToPb(oidcApp *query.OIDCApp) *app.Application_OidcConfig { + return &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + RedirectUris: oidcApp.RedirectURIs, + ResponseTypes: oidcResponseTypesFromModel(oidcApp.ResponseTypes), + GrantTypes: oidcGrantTypesFromModel(oidcApp.GrantTypes), + AppType: oidcApplicationTypeToPb(oidcApp.AppType), + ClientId: oidcApp.ClientID, + AuthMethodType: oidcAuthMethodTypeToPb(oidcApp.AuthMethodType), + PostLogoutRedirectUris: oidcApp.PostLogoutRedirectURIs, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + NoneCompliant: len(oidcApp.ComplianceProblems) != 0, + ComplianceProblems: ComplianceProblemsToLocalizedMessages(oidcApp.ComplianceProblems), + DevMode: oidcApp.IsDevMode, + AccessTokenType: oidcTokenTypeToPb(oidcApp.AccessTokenType), + AccessTokenRoleAssertion: oidcApp.AssertAccessTokenRole, + IdTokenRoleAssertion: oidcApp.AssertIDTokenRole, + IdTokenUserinfoAssertion: oidcApp.AssertIDTokenUserinfo, + ClockSkew: durationpb.New(oidcApp.ClockSkew), + AdditionalOrigins: oidcApp.AdditionalOrigins, + AllowedOrigins: oidcApp.AllowedOrigins, + SkipNativeAppSuccessPage: oidcApp.SkipNativeAppSuccessPage, + BackChannelLogoutUri: oidcApp.BackChannelLogoutURI, + LoginVersion: loginVersionToPb(oidcApp.LoginVersion, oidcApp.LoginBaseURI), + }, + } +} + +func oidcResponseTypesFromModel(responseTypes []domain.OIDCResponseType) []app.OIDCResponseType { + oidcResponseTypes := make([]app.OIDCResponseType, len(responseTypes)) + for i, responseType := range responseTypes { + switch responseType { + case domain.OIDCResponseTypeUnspecified: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED + case domain.OIDCResponseTypeCode: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE + case domain.OIDCResponseTypeIDToken: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN + case domain.OIDCResponseTypeIDTokenToken: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN + } + } + return oidcResponseTypes +} + +func oidcGrantTypesFromModel(grantTypes []domain.OIDCGrantType) []app.OIDCGrantType { + oidcGrantTypes := make([]app.OIDCGrantType, len(grantTypes)) + for i, grantType := range grantTypes { + switch grantType { + case domain.OIDCGrantTypeAuthorizationCode: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE + case domain.OIDCGrantTypeImplicit: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT + case domain.OIDCGrantTypeRefreshToken: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN + case domain.OIDCGrantTypeDeviceCode: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE + case domain.OIDCGrantTypeTokenExchange: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE + } + } + return oidcGrantTypes +} + +func oidcApplicationTypeToPb(appType domain.OIDCApplicationType) app.OIDCAppType { + switch appType { + case domain.OIDCApplicationTypeWeb: + return app.OIDCAppType_OIDC_APP_TYPE_WEB + case domain.OIDCApplicationTypeUserAgent: + return app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT + case domain.OIDCApplicationTypeNative: + return app.OIDCAppType_OIDC_APP_TYPE_NATIVE + default: + return app.OIDCAppType_OIDC_APP_TYPE_WEB + } +} + +func oidcAuthMethodTypeToPb(authType domain.OIDCAuthMethodType) app.OIDCAuthMethodType { + switch authType { + case domain.OIDCAuthMethodTypeBasic: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC + case domain.OIDCAuthMethodTypePost: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST + case domain.OIDCAuthMethodTypeNone: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE + case domain.OIDCAuthMethodTypePrivateKeyJWT: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT + default: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC + } +} + +func oidcTokenTypeToPb(tokenType domain.OIDCTokenType) app.OIDCTokenType { + switch tokenType { + case domain.OIDCTokenTypeBearer: + return app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER + case domain.OIDCTokenTypeJWT: + return app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT + default: + return app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER + } +} diff --git a/internal/api/grpc/app/v2beta/convert/oidc_app_test.go b/internal/api/grpc/app/v2beta/convert/oidc_app_test.go new file mode 100644 index 0000000000..a6b3f0b709 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/oidc_app_test.go @@ -0,0 +1,755 @@ +package convert + +import ( + "net/url" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func TestCreateOIDCAppRequestToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + projectID string + req *app.CreateOIDCApplicationRequest + + expectedModel *domain.OIDCApp + expectedError error + }{ + { + testName: "unparsable login version 2 URL", + projectID: "pid", + req: &app.CreateOIDCApplicationRequest{ + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}}, + }, + }, + expectedModel: nil, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "all fields set", + projectID: "project1", + req: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: true, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + AccessTokenRoleAssertion: true, + IdTokenRoleAssertion: true, + IdTokenUserinfoAssertion: true, + ClockSkew: durationpb.New(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: true, + BackChannelLogoutUri: "https://backchannel", + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{ + BaseUri: gu.Ptr("https://login"), + }}}, + }, + expectedModel: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "project1"}, + AppName: "all fields set", + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login"), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := CreateOIDCAppRequestToDomain(tc.testName, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedModel, res) + }) + } +} + +func TestUpdateOIDCAppConfigRequestToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + + appID string + projectID string + req *app.UpdateOIDCApplicationConfigurationRequest + + expectedModel *domain.OIDCApp + expectedError error + }{ + { + testName: "unparsable login version 2 URL", + appID: "app1", + projectID: "pid", + req: &app.UpdateOIDCApplicationConfigurationRequest{ + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}, + }}, + }, + expectedModel: nil, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "successful Update", + appID: "app1", + projectID: "proj1", + req: &app.UpdateOIDCApplicationConfigurationRequest{ + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: gu.Ptr(app.OIDCAppType_OIDC_APP_TYPE_WEB), + AuthMethodType: gu.Ptr(app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC), + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER), + AccessTokenRoleAssertion: gu.Ptr(true), + IdTokenRoleAssertion: gu.Ptr(true), + IdTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: durationpb.New(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutUri: gu.Ptr("https://backchannel"), + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("https://login")}, + }}, + }, + expectedModel: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj1"}, + AppID: "app1", + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login"), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + got, err := UpdateOIDCAppConfigRequestToDomain(tc.appID, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedModel, got) + }) + } +} + +func TestOIDCResponseTypesToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputResponseType []app.OIDCResponseType + expectedResponse []domain.OIDCResponseType + }{ + { + testName: "empty response types", + inputResponseType: []app.OIDCResponseType{}, + expectedResponse: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + }, + { + testName: "all response types", + inputResponseType: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN, + }, + expectedResponse: []domain.OIDCResponseType{ + domain.OIDCResponseTypeUnspecified, + domain.OIDCResponseTypeCode, + domain.OIDCResponseTypeIDToken, + domain.OIDCResponseTypeIDTokenToken, + }, + }, + { + testName: "single response type", + inputResponseType: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + }, + expectedResponse: []domain.OIDCResponseType{ + domain.OIDCResponseTypeCode, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res := oidcResponseTypesToDomain(tc.inputResponseType) + + // Then + assert.Equal(t, tc.expectedResponse, res) + }) + } +} + +func TestOIDCGrantTypesToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputGrantType []app.OIDCGrantType + expectedGrants []domain.OIDCGrantType + }{ + { + testName: "empty grant types", + inputGrantType: []app.OIDCGrantType{}, + expectedGrants: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + }, + { + testName: "all grant types", + inputGrantType: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT, + app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN, + app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE, + }, + expectedGrants: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + domain.OIDCGrantTypeImplicit, + domain.OIDCGrantTypeRefreshToken, + domain.OIDCGrantTypeDeviceCode, + domain.OIDCGrantTypeTokenExchange, + }, + }, + { + testName: "single grant type", + inputGrantType: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + }, + expectedGrants: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res := oidcGrantTypesToDomain(tc.inputGrantType) + + // Then + assert.Equal(t, tc.expectedGrants, res) + }) + } +} + +func TestOIDCApplicationTypeToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + appType app.OIDCAppType + expected domain.OIDCApplicationType + }{ + { + name: "web type", + appType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + expected: domain.OIDCApplicationTypeWeb, + }, + { + name: "user agent type", + appType: app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT, + expected: domain.OIDCApplicationTypeUserAgent, + }, + { + name: "native type", + appType: app.OIDCAppType_OIDC_APP_TYPE_NATIVE, + expected: domain.OIDCApplicationTypeNative, + }, + { + name: "unspecified type defaults to web", + expected: domain.OIDCApplicationTypeWeb, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcApplicationTypeToDomain(tc.appType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCAuthMethodTypeToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + authType app.OIDCAuthMethodType + expectedResponse domain.OIDCAuthMethodType + }{ + { + name: "basic auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + expectedResponse: domain.OIDCAuthMethodTypeBasic, + }, + { + name: "post auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST, + expectedResponse: domain.OIDCAuthMethodTypePost, + }, + { + name: "none auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, + expectedResponse: domain.OIDCAuthMethodTypeNone, + }, + { + name: "private key jwt auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + expectedResponse: domain.OIDCAuthMethodTypePrivateKeyJWT, + }, + { + name: "unspecified auth type defaults to basic", + expectedResponse: domain.OIDCAuthMethodTypeBasic, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + res := oidcAuthMethodTypeToDomain(tc.authType) + + // Then + assert.Equal(t, tc.expectedResponse, res) + }) + } +} + +func TestOIDCTokenTypeToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + tokenType app.OIDCTokenType + expectedType domain.OIDCTokenType + }{ + { + name: "bearer token type", + tokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + expectedType: domain.OIDCTokenTypeBearer, + }, + { + name: "jwt token type", + tokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + expectedType: domain.OIDCTokenTypeJWT, + }, + { + name: "unspecified defaults to bearer", + expectedType: domain.OIDCTokenTypeBearer, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcTokenTypeToDomain(tc.tokenType) + + // Then + assert.Equal(t, tc.expectedType, result) + }) + } +} +func TestAppOIDCConfigToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + input *query.OIDCApp + expected *app.Application_OidcConfig + }{ + { + name: "empty config", + input: &query.OIDCApp{}, + expected: &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + Version: app.OIDCVersion_OIDC_VERSION_1_0, + ComplianceProblems: []*app.OIDCLocalizedMessage{}, + ClockSkew: durationpb.New(0), + ResponseTypes: []app.OIDCResponseType{}, + GrantTypes: []app.OIDCGrantType{}, + }, + }, + }, + { + name: "full config", + input: &query.OIDCApp{ + RedirectURIs: []string{"https://example.com/callback"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + AppType: domain.OIDCApplicationTypeWeb, + ClientID: "client123", + AuthMethodType: domain.OIDCAuthMethodTypeBasic, + PostLogoutRedirectURIs: []string{"https://example.com/logout"}, + ComplianceProblems: []string{"problem1", "problem2"}, + IsDevMode: true, + AccessTokenType: domain.OIDCTokenTypeBearer, + AssertAccessTokenRole: true, + AssertIDTokenRole: true, + AssertIDTokenUserinfo: true, + ClockSkew: 5 * time.Second, + AdditionalOrigins: []string{"https://app.example.com"}, + AllowedOrigins: []string{"https://allowed.example.com"}, + SkipNativeAppSuccessPage: true, + BackChannelLogoutURI: "https://example.com/backchannel", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: gu.Ptr("https://login.example.com"), + }, + expected: &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + RedirectUris: []string{"https://example.com/callback"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + ClientId: "client123", + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"https://example.com/logout"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + NoneCompliant: true, + ComplianceProblems: []*app.OIDCLocalizedMessage{ + {Key: "problem1"}, + {Key: "problem2"}, + }, + DevMode: true, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + AccessTokenRoleAssertion: true, + IdTokenRoleAssertion: true, + IdTokenUserinfoAssertion: true, + ClockSkew: durationpb.New(5 * time.Second), + AdditionalOrigins: []string{"https://app.example.com"}, + AllowedOrigins: []string{"https://allowed.example.com"}, + SkipNativeAppSuccessPage: true, + BackChannelLogoutUri: "https://example.com/backchannel", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: gu.Ptr("https://login.example.com"), + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tt { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + result := appOIDCConfigToPb(tt.input) + + // Then + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestOIDCResponseTypesFromModel(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + responseTypes []domain.OIDCResponseType + expected []app.OIDCResponseType + }{ + { + name: "empty response types", + responseTypes: []domain.OIDCResponseType{}, + expected: []app.OIDCResponseType{}, + }, + { + name: "all response types", + responseTypes: []domain.OIDCResponseType{ + domain.OIDCResponseTypeUnspecified, + domain.OIDCResponseTypeCode, + domain.OIDCResponseTypeIDToken, + domain.OIDCResponseTypeIDTokenToken, + }, + expected: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN, + }, + }, + { + name: "single response type", + responseTypes: []domain.OIDCResponseType{ + domain.OIDCResponseTypeCode, + }, + expected: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcResponseTypesFromModel(tc.responseTypes) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} +func TestOIDCGrantTypesFromModel(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + grantTypes []domain.OIDCGrantType + expected []app.OIDCGrantType + }{ + { + name: "empty grant types", + grantTypes: []domain.OIDCGrantType{}, + expected: []app.OIDCGrantType{}, + }, + { + name: "all grant types", + grantTypes: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + domain.OIDCGrantTypeImplicit, + domain.OIDCGrantTypeRefreshToken, + domain.OIDCGrantTypeDeviceCode, + domain.OIDCGrantTypeTokenExchange, + }, + expected: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT, + app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN, + app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE, + }, + }, + { + name: "single grant type", + grantTypes: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + }, + expected: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcGrantTypesFromModel(tc.grantTypes) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCApplicationTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + appType domain.OIDCApplicationType + expected app.OIDCAppType + }{ + { + name: "web type", + appType: domain.OIDCApplicationTypeWeb, + expected: app.OIDCAppType_OIDC_APP_TYPE_WEB, + }, + { + name: "user agent type", + appType: domain.OIDCApplicationTypeUserAgent, + expected: app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT, + }, + { + name: "native type", + appType: domain.OIDCApplicationTypeNative, + expected: app.OIDCAppType_OIDC_APP_TYPE_NATIVE, + }, + { + name: "unspecified type defaults to web", + appType: domain.OIDCApplicationType(999), // Invalid value to trigger default case + expected: app.OIDCAppType_OIDC_APP_TYPE_WEB, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcApplicationTypeToPb(tc.appType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCAuthMethodTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + authType domain.OIDCAuthMethodType + expected app.OIDCAuthMethodType + }{ + { + name: "basic auth type", + authType: domain.OIDCAuthMethodTypeBasic, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + }, + { + name: "post auth type", + authType: domain.OIDCAuthMethodTypePost, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST, + }, + { + name: "none auth type", + authType: domain.OIDCAuthMethodTypeNone, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, + }, + { + name: "private key jwt auth type", + authType: domain.OIDCAuthMethodTypePrivateKeyJWT, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + { + name: "unknown auth type defaults to basic", + authType: domain.OIDCAuthMethodType(999), + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcAuthMethodTypeToPb(tc.authType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCTokenTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + tokenType domain.OIDCTokenType + expected app.OIDCTokenType + }{ + { + name: "bearer token type", + tokenType: domain.OIDCTokenTypeBearer, + expected: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + }, + { + name: "jwt token type", + tokenType: domain.OIDCTokenTypeJWT, + expected: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + }, + { + name: "unknown token type defaults to bearer", + tokenType: domain.OIDCTokenType(999), // Invalid value to trigger default case + expected: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcTokenTypeToPb(tc.tokenType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/convert/saml_app.go b/internal/api/grpc/app/v2beta/convert/saml_app.go new file mode 100644 index 0000000000..7f1bef082b --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/saml_app.go @@ -0,0 +1,77 @@ +package convert + +import ( + "github.com/muhlemmer/gu" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func CreateSAMLAppRequestToDomain(name, projectID string, req *app.CreateSAMLApplicationRequest) (*domain.SAMLApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(req.GetLoginVersion()) + if err != nil { + return nil, err + } + return &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppName: name, + Metadata: req.GetMetadataXml(), + MetadataURL: gu.Ptr(req.GetMetadataUrl()), + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func UpdateSAMLAppConfigRequestToDomain(appID, projectID string, app *app.UpdateSAMLApplicationConfigurationRequest) (*domain.SAMLApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(app.GetLoginVersion()) + if err != nil { + return nil, err + } + + metasXML, metasURL := metasToDomain(app.GetMetadata()) + return &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppID: appID, + Metadata: metasXML, + MetadataURL: metasURL, + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func metasToDomain(metas app.MetaType) ([]byte, *string) { + switch t := metas.(type) { + case *app.UpdateSAMLApplicationConfigurationRequest_MetadataXml: + return t.MetadataXml, nil + case *app.UpdateSAMLApplicationConfigurationRequest_MetadataUrl: + return nil, &t.MetadataUrl + case nil: + return nil, nil + default: + return nil, nil + } +} + +func appSAMLConfigToPb(samlApp *query.SAMLApp) app.ApplicationConfig { + if samlApp == nil { + return &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{}, + LoginVersion: &app.LoginVersion{}, + }, + } + } + + return &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{MetadataXml: samlApp.Metadata}, + LoginVersion: loginVersionToPb(samlApp.LoginVersion, samlApp.LoginBaseURI), + }, + } +} diff --git a/internal/api/grpc/app/v2beta/convert/saml_app_test.go b/internal/api/grpc/app/v2beta/convert/saml_app_test.go new file mode 100644 index 0000000000..b41ec432b6 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/saml_app_test.go @@ -0,0 +1,256 @@ +package convert + +import ( + "fmt" + "net/url" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func samlMetadataGen(entityID string) []byte { + str := fmt.Sprintf(` + + + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified + + + + +`, + entityID) + + return []byte(str) +} + +func TestCreateSAMLAppRequestToDomain(t *testing.T) { + t.Parallel() + + genMetaForValidRequest := samlMetadataGen(gofakeit.URL()) + + tt := []struct { + testName string + appName string + projectID string + req *app.CreateSAMLApplicationRequest + + expectedResponse *domain.SAMLApp + expectedError error + }{ + { + testName: "login version error", + appName: "test-app", + projectID: "proj-1", + req: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}, + }, + }, + }, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "valid request", + appName: "test-app", + projectID: "proj-1", + req: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: genMetaForValidRequest, + }, + LoginVersion: nil, + }, + + expectedResponse: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppName: "test-app", + Metadata: genMetaForValidRequest, + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + State: 0, + }, + }, + { + testName: "nil request", + appName: "test-app", + projectID: "proj-1", + req: nil, + + expectedResponse: &domain.SAMLApp{ + AppName: "test-app", + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := CreateSAMLAppRequestToDomain(tc.appName, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResponse, res) + }) + } +} +func TestUpdateSAMLAppConfigRequestToDomain(t *testing.T) { + t.Parallel() + + genMetaForValidRequest := samlMetadataGen(gofakeit.URL()) + + tt := []struct { + testName string + appID string + projectID string + req *app.UpdateSAMLApplicationConfigurationRequest + + expectedResponse *domain.SAMLApp + expectedError error + }{ + { + testName: "login version error", + appID: "app-1", + projectID: "proj-1", + req: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}, + }, + }, + }, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "valid request", + appID: "app-1", + projectID: "proj-1", + req: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: genMetaForValidRequest, + }, + LoginVersion: nil, + }, + expectedResponse: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppID: "app-1", + Metadata: genMetaForValidRequest, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + }, + }, + { + testName: "nil request", + appID: "app-1", + projectID: "proj-1", + req: nil, + expectedResponse: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppID: "app-1", + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := UpdateSAMLAppConfigRequestToDomain(tc.appID, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResponse, res) + }) + } +} + +func TestAppSAMLConfigToPb(t *testing.T) { + t.Parallel() + + metadata := samlMetadataGen(gofakeit.URL()) + + tt := []struct { + name string + inputSAMLApp *query.SAMLApp + + expectedPbApp app.ApplicationConfig + }{ + { + name: "valid conversion", + inputSAMLApp: &query.SAMLApp{ + Metadata: metadata, + LoginVersion: domain.LoginVersion2, + LoginBaseURI: gu.Ptr("https://example.com"), + }, + expectedPbApp: &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{ + MetadataXml: metadata, + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("https://example.com")}, + }, + }, + }, + }, + }, + { + name: "nil saml app", + inputSAMLApp: nil, + expectedPbApp: &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{}, + LoginVersion: &app.LoginVersion{}, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + got := appSAMLConfigToPb(tc.inputSAMLApp) + + // Then + assert.Equal(t, tc.expectedPbApp, got) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/integration_test/app_test.go b/internal/api/grpc/app/v2beta/integration_test/app_test.go new file mode 100644 index 0000000000..1ba46987cf --- /dev/null +++ b/internal/api/grpc/app/v2beta/integration_test/app_test.go @@ -0,0 +1,1446 @@ +//go:build integration + +package instance_test + +import ( + "context" + "fmt" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" +) + +func TestCreateApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + t.Parallel() + + notExistingProjectID := gofakeit.UUID() + + tt := []struct { + testName string + creationRequest *app.CreateApplicationRequest + inputCtx context.Context + + expectedResponseType string + expectedErrorType codes.Code + }{ + { + testName: "when project for API app creation is not found should return failed precondition error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: notExistingProjectID, + Name: "App Name", + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedErrorType: codes.FailedPrecondition, + }, + { + testName: "when CreateAPIApp request is valid should create app and return no error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: "App Name", + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_ApiResponse{}), + }, + { + testName: "when project for OIDC app creation is not found should return failed precondition error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: notExistingProjectID, + Name: "App Name", + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedErrorType: codes.FailedPrecondition, + }, + { + testName: "when CreateOIDCApp request is valid should create app and return no error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_OidcResponse{}), + }, + { + testName: "when project for SAML app creation is not found should return failed precondition error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: notExistingProjectID, + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataUrl{ + MetadataUrl: "http://example.com/metas", + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedErrorType: codes.FailedPrecondition, + }, + { + testName: "when CreateSAMLApp request is valid should create app and return no error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_SamlResponse{}), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.CreateApplication(tc.inputCtx, tc.creationRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + resType := fmt.Sprintf("%T", res.GetCreationResponseType()) + assert.Equal(t, tc.expectedResponseType, resType) + assert.NotZero(t, res.GetAppId()) + assert.NotZero(t, res.GetCreationDate()) + } + }) + } +} + +func TestCreateApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + t.Parallel() + + tt := []struct { + testName string + creationRequest *app.CreateApplicationRequest + inputCtx context.Context + + expectedResponseType string + expectedErrorType codes.Code + }{ + // Login User with no project.app.write + { + testName: "when user has no project.app.write permission for API request should return permission error", + inputCtx: LoginUserCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.Name(), + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for OIDC request should return permission error", + inputCtx: LoginUserCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for SAML request should return permission error", + inputCtx: LoginUserCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + + // OrgOwner with project.app.write permission + { + testName: "when user is OrgOwner API request should succeed", + inputCtx: OrgOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.Name(), + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_ApiResponse{}), + }, + { + testName: "when user is OrgOwner OIDC request should succeed", + inputCtx: OrgOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_OidcResponse{}), + }, + { + testName: "when user is OrgOwner SAML request should succeed", + inputCtx: OrgOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_SamlResponse{}), + }, + + // Project owner with project.app.write permission + { + testName: "when user is ProjectOwner API request should succeed", + inputCtx: projectOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.Name(), + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_ApiResponse{}), + }, + { + testName: "when user is ProjectOwner OIDC request should succeed", + inputCtx: projectOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_OidcResponse{}), + }, + { + testName: "when user is ProjectOwner SAML request should succeed", + inputCtx: projectOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_SamlResponse{}), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.CreateApplication(tc.inputCtx, tc.creationRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + resType := fmt.Sprintf("%T", res.GetCreationResponseType()) + assert.Equal(t, tc.expectedResponseType, resType) + assert.NotZero(t, res.GetAppId()) + assert.NotZero(t, res.GetCreationDate()) + } + }) + } +} + +func TestUpdateApplication(t *testing.T) { + orgNotInCtx := instance.CreateOrganization(IAMOwnerCtx, gofakeit.Name(), gofakeit.Email()) + pNotInCtx := instance.CreateProject(IAMOwnerCtx, t, orgNotInCtx.GetOrganizationId(), gofakeit.AppName(), false, false) + + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + baseURI := "http://example.com" + + t.Cleanup(func() { + instance.Client.OrgV2beta.DeleteOrganization(IAMOwnerCtx, &org.DeleteOrganizationRequest{ + Id: orgNotInCtx.GetOrganizationId(), + }) + }) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + reqForAPIAppCreation := reqForAppNameCreation + + reqForOIDCAppCreation := &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + } + + samlMetas := samlMetadataGen(gofakeit.URL()) + reqForSAMLAppCreation := &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + } + + appForNameChange, appNameChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appNameChangeErr) + + appForAPIConfigChange, appAPIConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + appForOIDCConfigChange, appOIDCConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForOIDCAppCreation, + }) + require.Nil(t, appOIDCConfigChangeErr) + + appForSAMLConfigChange, appSAMLConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForSAMLAppCreation, + }) + require.Nil(t, appSAMLConfigChangeErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + updateRequest *app.UpdateApplicationRequest + + expectedErrorType codes.Code + }{ + { + testName: "when app for app name change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForNameChange.GetAppId(), + Name: "New name", + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for app name change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: "New name", + }, + }, + + { + testName: "when app for API config change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForAPIConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for API config change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + }, + { + testName: "when app for OIDC config change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForOIDCConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for OIDC config change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + }, + + { + testName: "when app for SAML config change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForSAMLConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for SAML config change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.UpdateApplication(tc.inputCtx, tc.updateRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetChangeDate()) + } + }) + } +} + +func TestUpdateApplication_WithDifferentPermissions(t *testing.T) { + baseURI := "http://example.com" + + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appForNameChange, appNameChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appNameChangeErr) + + appForAPIConfigChangeForProjectOwner := createAPIApp(t, p.GetId()) + appForAPIConfigChangeForOrgOwner := createAPIApp(t, p.GetId()) + appForAPIConfigChangeForLoginUser := createAPIApp(t, p.GetId()) + + appForOIDCConfigChangeForProjectOwner := createOIDCApp(t, baseURI, p.GetId()) + appForOIDCConfigChangeForOrgOwner := createOIDCApp(t, baseURI, p.GetId()) + appForOIDCConfigChangeForLoginUser := createOIDCApp(t, baseURI, p.GetId()) + + samlMetasForProjectOwner, appForSAMLConfigChangeForProjectOwner := createSAMLApp(t, baseURI, p.GetId()) + samlMetasForOrgOwner, appForSAMLConfigChangeForOrgOwner := createSAMLApp(t, baseURI, p.GetId()) + samlMetasForLoginUser, appForSAMLConfigChangeForLoginUser := createSAMLApp(t, baseURI, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + updateRequest *app.UpdateApplicationRequest + + expectedErrorType codes.Code + }{ + // ProjectOwner + { + testName: "when user is ProjectOwner app name request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: gofakeit.AppName(), + }, + }, + { + testName: "when user is ProjectOwner API app request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChangeForProjectOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + }, + { + testName: "when user is ProjectOwner OIDC app request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChangeForProjectOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + }, + { + testName: "when user is ProjectOwner SAML request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChangeForProjectOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetasForProjectOwner, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + }, + + // OrgOwner context + { + testName: "when user is OrgOwner app name request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: gofakeit.AppName(), + }, + }, + { + testName: "when user is OrgOwner API app request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChangeForOrgOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + }, + { + testName: "when user is OrgOwner OIDC app request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChangeForOrgOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + }, + { + testName: "when user is OrgOwner SAML request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChangeForOrgOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetasForOrgOwner, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + }, + + // LoginUser + { + testName: "when user has no project.app.write permission for app name change request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: gofakeit.AppName(), + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for API request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChangeForLoginUser.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for OIDC request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChangeForLoginUser.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for SAML request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChangeForLoginUser.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetasForLoginUser, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.UpdateApplication(tc.inputCtx, tc.updateRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetChangeDate()) + } + }) + } +} + +func TestDeleteApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appToDelete, appNameChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appNameChangeErr) + + t.Parallel() + tt := []struct { + testName string + deleteRequest *app.DeleteApplicationRequest + inputCtx context.Context + + expectedErrorType codes.Code + }{ + { + testName: "when app to delete is not found should return not found error", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: gofakeit.Sentence(2), + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when app to delete is found should return deletion time", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDelete.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeleteApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeletionDate()) + } + }) + } +} + +func TestDeleteApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + appToDeleteForLoginUser := createAPIApp(t, p.GetId()) + appToDeleteForProjectOwner := createAPIApp(t, p.GetId()) + appToDeleteForOrgOwner := createAPIApp(t, p.GetId()) + + t.Parallel() + tt := []struct { + testName string + deleteRequest *app.DeleteApplicationRequest + inputCtx context.Context + + expectedErrorType codes.Code + }{ + // Login User + { + testName: "when user has no project.app.delete permission for app delete request should return permission error", + inputCtx: LoginUserCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeleteForLoginUser.GetAppId(), + }, + expectedErrorType: codes.PermissionDenied, + }, + + // Project Owner + { + testName: "when user is ProjectOwner delete app request should succeed", + inputCtx: projectOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeleteForProjectOwner.GetAppId(), + }, + }, + + // Org Owner + { + testName: "when user is OrgOwner delete app request should succeed", + inputCtx: projectOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeleteForOrgOwner.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeleteApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeletionDate()) + } + }) + } +} + +func TestDeactivateApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appToDeactivate, appCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.NoError(t, appCreateErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + deleteRequest *app.DeactivateApplicationRequest + + expectedErrorType codes.Code + }{ + { + testName: "when app to deactivate is not found should return not found error", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: gofakeit.Sentence(2), + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when app to deactivate is found should return deactivation time", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivate.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeactivateApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeactivationDate()) + } + }) + } +} + +func TestDeactivateApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + appToDeactivateForLoginUser := createAPIApp(t, p.GetId()) + appToDeactivateForPrjectOwner := createAPIApp(t, p.GetId()) + appToDeactivateForOrgOwner := createAPIApp(t, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + deleteRequest *app.DeactivateApplicationRequest + + expectedErrorType codes.Code + }{ + // Login User + { + testName: "when user has no project.app.write permission for app deactivate request should return permission error", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivateForLoginUser.GetAppId(), + }, + }, + + // Project Owner + { + testName: "when user is ProjectOwner deactivate app request should succeed", + inputCtx: projectOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivateForPrjectOwner.GetAppId(), + }, + }, + + // Org Owner + { + testName: "when user is OrgOwner deactivate app request should succeed", + inputCtx: OrgOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivateForOrgOwner.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeactivateApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeactivationDate()) + } + }) + } +} + +func TestReactivateApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appToReactivate, appCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appCreateErr) + + _, appDeactivateErr := instance.Client.AppV2Beta.DeactivateApplication(IAMOwnerCtx, &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivate.GetAppId(), + }) + require.Nil(t, appDeactivateErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + reactivateRequest *app.ReactivateApplicationRequest + + expectedErrorType codes.Code + }{ + { + testName: "when app to reactivate is not found should return not found error", + inputCtx: IAMOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: gofakeit.Sentence(2), + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when app to reactivate is found should return deactivation time", + inputCtx: IAMOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivate.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.ReactivateApplication(tc.inputCtx, tc.reactivateRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetReactivationDate()) + } + }) + } +} + +func TestReactivateApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + appToReactivateForLoginUser := createAPIApp(t, p.GetId()) + deactivateApp(t, appToReactivateForLoginUser, p.GetId()) + + appToReactivateForProjectOwner := createAPIApp(t, p.GetId()) + deactivateApp(t, appToReactivateForProjectOwner, p.GetId()) + + appToReactivateForOrgOwner := createAPIApp(t, p.GetId()) + deactivateApp(t, appToReactivateForOrgOwner, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + reactivateRequest *app.ReactivateApplicationRequest + + expectedErrorType codes.Code + }{ + // Login User + { + testName: "when user has no project.app.write permission for app reactivate request should return permission error", + inputCtx: LoginUserCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivateForLoginUser.GetAppId(), + }, + expectedErrorType: codes.PermissionDenied, + }, + + // Project Owner + { + testName: "when user is ProjectOwner reactivate app request should succeed", + inputCtx: projectOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivateForProjectOwner.GetAppId(), + }, + }, + + // Org Owner + { + testName: "when user is OrgOwner reactivate app request should succeed", + inputCtx: OrgOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivateForOrgOwner.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.ReactivateApplication(tc.inputCtx, tc.reactivateRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetReactivationDate()) + } + }) + } +} + +func TestRegenerateClientSecret(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForApiAppCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + apiAppToRegen, apiAppCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForApiAppCreation, + }) + require.Nil(t, apiAppCreateErr) + + reqForOIDCAppCreation := &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + } + + oidcAppToRegen, oidcAppCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForOIDCAppCreation, + }) + require.Nil(t, oidcAppCreateErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + regenRequest *app.RegenerateClientSecretRequest + + expectedErrorType codes.Code + oldSecret string + }{ + { + testName: "when app to regen is not expected type should return invalid argument error", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: gofakeit.Sentence(2), + }, + expectedErrorType: codes.InvalidArgument, + }, + { + testName: "when app to regen is not found should return not found error", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: gofakeit.Sentence(2), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when API app to regen is found should return different secret", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegen.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + oldSecret: apiAppToRegen.GetApiResponse().GetClientSecret(), + }, + { + testName: "when OIDC app to regen is found should return different secret", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegen.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + oldSecret: oidcAppToRegen.GetOidcResponse().GetClientSecret(), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.RegenerateClientSecret(tc.inputCtx, tc.regenRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetCreationDate()) + assert.NotEqual(t, tc.oldSecret, res.GetClientSecret()) + } + }) + } + +} + +func TestRegenerateClientSecret_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + apiAppToRegenForLoginUser := createAPIApp(t, p.GetId()) + apiAppToRegenForProjectOwner := createAPIApp(t, p.GetId()) + apiAppToRegenForOrgOwner := createAPIApp(t, p.GetId()) + + oidcAppToRegenForLoginUser := createOIDCApp(t, baseURI, p.GetId()) + oidcAppToRegenForProjectOwner := createOIDCApp(t, baseURI, p.GetId()) + oidcAppToRegenForOrgOwner := createOIDCApp(t, baseURI, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + regenRequest *app.RegenerateClientSecretRequest + + expectedErrorType codes.Code + oldSecret string + }{ + // Login user + { + testName: "when user has no project.app.write permission for API app secret regen request should return permission error", + inputCtx: LoginUserCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegenForLoginUser.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for OIDC app secret regen request should return permission error", + inputCtx: LoginUserCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegenForLoginUser.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + expectedErrorType: codes.PermissionDenied, + }, + + // Project Owner + { + testName: "when user is ProjectOwner regen API app secret request should succeed", + inputCtx: projectOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegenForProjectOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + oldSecret: apiAppToRegenForProjectOwner.GetApiResponse().GetClientSecret(), + }, + { + testName: "when user is ProjectOwner regen OIDC app secret request should succeed", + inputCtx: projectOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegenForProjectOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + oldSecret: oidcAppToRegenForProjectOwner.GetOidcResponse().GetClientSecret(), + }, + + // Org Owner + { + testName: "when user is OrgOwner regen API app secret request should succeed", + inputCtx: OrgOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegenForOrgOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + oldSecret: apiAppToRegenForOrgOwner.GetApiResponse().GetClientSecret(), + }, + { + testName: "when user is OrgOwner regen OIDC app secret request should succeed", + inputCtx: OrgOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegenForOrgOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + oldSecret: oidcAppToRegenForOrgOwner.GetOidcResponse().GetClientSecret(), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.RegenerateClientSecret(tc.inputCtx, tc.regenRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetCreationDate()) + assert.NotEqual(t, tc.oldSecret, res.GetClientSecret()) + } + }) + } + +} diff --git a/internal/api/grpc/app/v2beta/integration_test/query_test.go b/internal/api/grpc/app/v2beta/integration_test/query_test.go new file mode 100644 index 0000000000..578fcec138 --- /dev/null +++ b/internal/api/grpc/app/v2beta/integration_test/query_test.go @@ -0,0 +1,575 @@ +//go:build integration + +package instance_test + +import ( + "context" + "fmt" + "slices" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/zitadel/zitadel/internal/integration" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" +) + +func TestGetApplication(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + apiAppName := gofakeit.AppName() + createdApiApp, errAPIAppCreation := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: apiAppName, + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }) + require.Nil(t, errAPIAppCreation) + + samlAppName := gofakeit.AppName() + createdSAMLApp, errSAMLAppCreation := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: samlAppName, + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{MetadataXml: samlMetadataGen(gofakeit.URL())}, + }, + }, + }) + require.Nil(t, errSAMLAppCreation) + + oidcAppName := gofakeit.AppName() + createdOIDCApp, errOIDCAppCreation := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: oidcAppName, + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: &baseURI}}}, + }, + }, + }) + require.Nil(t, errOIDCAppCreation) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.GetApplicationRequest + inputCtx context.Context + + expectedErrorType codes.Code + expectedAppName string + expectedAppID string + expectedApplicationType string + }{ + { + testName: "when unknown app ID should return not found error", + inputCtx: IAMOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: gofakeit.Sentence(2), + }, + + expectedErrorType: codes.NotFound, + }, + { + testName: "when user has no permission should return membership not found error", + inputCtx: NoPermissionCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdApiApp.GetAppId(), + }, + + expectedErrorType: codes.NotFound, + }, + { + testName: "when providing API app ID should return valid API app result", + inputCtx: projectOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdApiApp.GetAppId(), + }, + + expectedAppName: apiAppName, + expectedAppID: createdApiApp.GetAppId(), + expectedApplicationType: fmt.Sprintf("%T", &app.Application_ApiConfig{}), + }, + { + testName: "when providing SAML app ID should return valid SAML app result", + inputCtx: IAMOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdSAMLApp.GetAppId(), + }, + + expectedAppName: samlAppName, + expectedAppID: createdSAMLApp.GetAppId(), + expectedApplicationType: fmt.Sprintf("%T", &app.Application_SamlConfig{}), + }, + { + testName: "when providing OIDC app ID should return valid OIDC app result", + inputCtx: IAMOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdOIDCApp.GetAppId(), + }, + + expectedAppName: oidcAppName, + expectedAppID: createdOIDCApp.GetAppId(), + expectedApplicationType: fmt.Sprintf("%T", &app.Application_OidcConfig{}), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 30*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instance.Client.AppV2Beta.GetApplication(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + + assert.Equal(t, tc.expectedAppID, res.GetApp().GetId()) + assert.Equal(t, tc.expectedAppName, res.GetApp().GetName()) + assert.NotZero(t, res.GetApp().GetCreationDate()) + assert.NotZero(t, res.GetApp().GetChangeDate()) + + appType := fmt.Sprintf("%T", res.GetApp().GetConfig()) + assert.Equal(t, tc.expectedApplicationType, appType) + } + }, retryDuration, tick) + }) + } +} + +func TestListApplications(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + t.Parallel() + + createdApiApp, apiAppName := createAPIAppWithName(t, p.GetId()) + + createdDeactivatedApiApp, deactivatedApiAppName := createAPIAppWithName(t, p.GetId()) + deactivateApp(t, createdDeactivatedApiApp, p.GetId()) + + _, createdSAMLApp, samlAppName := createSAMLAppWithName(t, gofakeit.URL(), p.GetId()) + + createdOIDCApp, oidcAppName := createOIDCAppWithName(t, gofakeit.URL(), p.GetId()) + + type appWithName struct { + app *app.CreateApplicationResponse + name string + } + + // Sorting + appsSortedByName := []appWithName{ + {name: apiAppName, app: createdApiApp}, + {name: deactivatedApiAppName, app: createdDeactivatedApiApp}, + {name: samlAppName, app: createdSAMLApp}, + {name: oidcAppName, app: createdOIDCApp}, + } + slices.SortFunc(appsSortedByName, func(a, b appWithName) int { + if a.name < b.name { + return -1 + } + if a.name > b.name { + return 1 + } + + return 0 + }) + + appsSortedByID := []appWithName{ + {name: apiAppName, app: createdApiApp}, + {name: deactivatedApiAppName, app: createdDeactivatedApiApp}, + {name: samlAppName, app: createdSAMLApp}, + {name: oidcAppName, app: createdOIDCApp}, + } + slices.SortFunc(appsSortedByID, func(a, b appWithName) int { + if a.app.GetAppId() < b.app.GetAppId() { + return -1 + } + if a.app.GetAppId() > b.app.GetAppId() { + return 1 + } + return 0 + }) + + appsSortedByCreationDate := []appWithName{ + {name: apiAppName, app: createdApiApp}, + {name: deactivatedApiAppName, app: createdDeactivatedApiApp}, + {name: samlAppName, app: createdSAMLApp}, + {name: oidcAppName, app: createdOIDCApp}, + } + slices.SortFunc(appsSortedByCreationDate, func(a, b appWithName) int { + aCreationDate := a.app.GetCreationDate().AsTime() + bCreationDate := b.app.GetCreationDate().AsTime() + + if aCreationDate.Before(bCreationDate) { + return -1 + } + if bCreationDate.Before(aCreationDate) { + return 1 + } + + return 0 + }) + + tt := []struct { + testName string + inputRequest *app.ListApplicationsRequest + inputCtx context.Context + + expectedOrderedList []appWithName + expectedOrderedKeys func(keys []appWithName) any + actualOrderedKeys func(keys []*app.Application) any + }{ + { + testName: "when no apps found should return empty list", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: "another-id", + }, + + expectedOrderedList: []appWithName{}, + expectedOrderedKeys: func(keys []appWithName) any { return keys }, + actualOrderedKeys: func(keys []*app.Application) any { return keys }, + }, + { + testName: "when user has no read permission should return empty set", + inputCtx: NoPermissionCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedOrderedList: []appWithName{}, + expectedOrderedKeys: func(keys []appWithName) any { return keys }, + actualOrderedKeys: func(keys []*app.Application) any { return keys }, + }, + { + testName: "when sorting by name should return apps sorted by name in descending order", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_NAME, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + + expectedOrderedList: appsSortedByName, + expectedOrderedKeys: func(apps []appWithName) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.name + } + + return names + }, + actualOrderedKeys: func(apps []*app.Application) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.GetName() + } + + return names + }, + }, + + { + testName: "when user is project owner should return apps sorted by name in ascending order", + inputCtx: projectOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_NAME, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + + expectedOrderedList: appsSortedByName, + expectedOrderedKeys: func(apps []appWithName) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.name + } + + return names + }, + actualOrderedKeys: func(apps []*app.Application) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.GetName() + } + + return names + }, + }, + + { + testName: "when sorting by id should return apps sorted by id in descending order", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_ID, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + expectedOrderedList: appsSortedByID, + expectedOrderedKeys: func(apps []appWithName) any { + ids := make([]string, len(apps)) + for i, a := range apps { + ids[i] = a.app.GetAppId() + } + + return ids + }, + actualOrderedKeys: func(apps []*app.Application) any { + ids := make([]string, len(apps)) + for i, a := range apps { + ids[i] = a.GetId() + } + + return ids + }, + }, + { + testName: "when sorting by creation date should return apps sorted by creation date in descending order", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_CREATION_DATE, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + expectedOrderedList: appsSortedByCreationDate, + expectedOrderedKeys: func(apps []appWithName) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.app.GetCreationDate().AsTime() + } + + return creationDates + }, + actualOrderedKeys: func(apps []*app.Application) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.GetCreationDate().AsTime() + } + + return creationDates + }, + }, + { + testName: "when filtering by active apps should return active apps only", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + Pagination: &filter.PaginationRequest{Asc: true}, + Filters: []*app.ApplicationSearchFilter{ + {Filter: &app.ApplicationSearchFilter_StateFilter{StateFilter: app.AppState_APP_STATE_ACTIVE}}, + }, + }, + expectedOrderedList: slices.DeleteFunc( + slices.Clone(appsSortedByID), + func(a appWithName) bool { return a.name == deactivatedApiAppName }, + ), + expectedOrderedKeys: func(apps []appWithName) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.app.GetCreationDate().AsTime() + } + + return creationDates + }, + actualOrderedKeys: func(apps []*app.Application) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.GetCreationDate().AsTime() + } + + return creationDates + }, + }, + { + testName: "when filtering by app type should return apps of matching type only", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + Pagination: &filter.PaginationRequest{Asc: true}, + Filters: []*app.ApplicationSearchFilter{ + {Filter: &app.ApplicationSearchFilter_OidcAppOnly{}}, + }, + }, + expectedOrderedList: slices.DeleteFunc( + slices.Clone(appsSortedByID), + func(a appWithName) bool { return a.name != oidcAppName }, + ), + expectedOrderedKeys: func(apps []appWithName) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.app.GetCreationDate().AsTime() + } + + return creationDates + }, + actualOrderedKeys: func(apps []*app.Application) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.GetCreationDate().AsTime() + } + + return creationDates + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 30*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instance.Client.AppV2Beta.ListApplications(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(ttt, codes.OK, status.Code(err)) + + if err == nil { + assert.Len(ttt, res.GetApplications(), len(tc.expectedOrderedList)) + actualOrderedKeys := tc.actualOrderedKeys(res.GetApplications()) + expectedOrderedKeys := tc.expectedOrderedKeys(tc.expectedOrderedList) + assert.ElementsMatch(ttt, expectedOrderedKeys, actualOrderedKeys) + } + }, retryDuration, tick) + }) + } +} + +func TestListApplications_WithPermissionV2(t *testing.T) { + ensureFeaturePermissionV2Enabled(t, instancePermissionV2) + iamOwnerCtx := instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeIAMOwner) + p, projectOwnerCtx := getProjectAndProjectContext(t, instancePermissionV2, iamOwnerCtx) + _, otherProjectOwnerCtx := getProjectAndProjectContext(t, instancePermissionV2, iamOwnerCtx) + + appName1, appName2, appName3 := gofakeit.AppName(), gofakeit.AppName(), gofakeit.AppName() + reqForAPIAppCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + app1, appAPIConfigChangeErr := instancePermissionV2.Client.AppV2Beta.CreateApplication(iamOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: appName1, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + app2, appAPIConfigChangeErr := instancePermissionV2.Client.AppV2Beta.CreateApplication(iamOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: appName2, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + app3, appAPIConfigChangeErr := instancePermissionV2.Client.AppV2Beta.CreateApplication(iamOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: appName3, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.ListApplicationsRequest + inputCtx context.Context + + expectedCode codes.Code + expectedAppIDs []string + }{ + { + testName: "when user has no read permission should return empty set", + inputCtx: instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeNoPermission), + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{}, + }, + { + testName: "when projectOwner should return full app list", + inputCtx: projectOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedCode: codes.OK, + expectedAppIDs: []string{app1.GetAppId(), app2.GetAppId(), app3.GetAppId()}, + }, + { + testName: "when orgOwner should return full app list", + inputCtx: instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{app1.GetAppId(), app2.GetAppId(), app3.GetAppId()}, + }, + { + testName: "when iamOwner user should return full app list", + inputCtx: iamOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{app1.GetAppId(), app2.GetAppId(), app3.GetAppId()}, + }, + { + testName: "when other projectOwner user should return empty list", + inputCtx: otherProjectOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 5*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instancePermissionV2.Client.AppV2Beta.ListApplications(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(ttt, tc.expectedCode, status.Code(err)) + + if err == nil { + require.Len(ttt, res.GetApplications(), len(tc.expectedAppIDs)) + + resAppIDs := []string{} + for _, a := range res.GetApplications() { + resAppIDs = append(resAppIDs, a.GetId()) + } + + assert.ElementsMatch(ttt, tc.expectedAppIDs, resAppIDs) + } + }, retryDuration, tick) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/integration_test/server_test.go b/internal/api/grpc/app/v2beta/integration_test/server_test.go new file mode 100644 index 0000000000..6618ab0616 --- /dev/null +++ b/internal/api/grpc/app/v2beta/integration_test/server_test.go @@ -0,0 +1,205 @@ +//go:build integration + +package instance_test + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" + project_v2beta "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +var ( + NoPermissionCtx context.Context + LoginUserCtx context.Context + OrgOwnerCtx context.Context + IAMOwnerCtx context.Context + + instance *integration.Instance + instancePermissionV2 *integration.Instance + + baseURI = "http://example.com" +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + instance = integration.NewInstance(ctx) + instancePermissionV2 = integration.NewInstance(ctx) + + IAMOwnerCtx = instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + + LoginUserCtx = instance.WithAuthorization(ctx, integration.UserTypeLogin) + OrgOwnerCtx = instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + NoPermissionCtx = instance.WithAuthorization(ctx, integration.UserTypeNoPermission) + + return m.Run() + }()) +} + +func getProjectAndProjectContext(t *testing.T, inst *integration.Instance, ctx context.Context) (*project_v2beta.CreateProjectResponse, context.Context) { + project := inst.CreateProject(ctx, t, inst.DefaultOrg.GetId(), gofakeit.Name(), false, false) + userResp := inst.CreateMachineUser(ctx) + patResp := inst.CreatePersonalAccessToken(ctx, userResp.GetUserId()) + inst.CreateProjectMembership(t, ctx, project.GetId(), userResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(context.Background(), patResp.Token) + + return project, projectOwnerCtx +} + +func samlMetadataGen(entityID string) []byte { + str := fmt.Sprintf(` + + + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified + + + + +`, + entityID) + + return []byte(str) +} + +func createSAMLAppWithName(t *testing.T, baseURI, projectID string) ([]byte, *app.CreateApplicationResponse, string) { + samlMetas := samlMetadataGen(gofakeit.URL()) + appName := gofakeit.AppName() + + appForSAMLConfigChange, appSAMLConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: projectID, + Name: appName, + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }) + require.Nil(t, appSAMLConfigChangeErr) + + return samlMetas, appForSAMLConfigChange, appName +} + +func createSAMLApp(t *testing.T, baseURI, projectID string) ([]byte, *app.CreateApplicationResponse) { + metas, app, _ := createSAMLAppWithName(t, baseURI, projectID) + return metas, app +} + +func createOIDCAppWithName(t *testing.T, baseURI, projectID string) (*app.CreateApplicationResponse, string) { + appName := gofakeit.AppName() + + appForOIDCConfigChange, appOIDCConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: projectID, + Name: appName, + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }) + require.Nil(t, appOIDCConfigChangeErr) + + return appForOIDCConfigChange, appName +} + +func createOIDCApp(t *testing.T, baseURI, projctID string) *app.CreateApplicationResponse { + app, _ := createOIDCAppWithName(t, baseURI, projctID) + + return app +} + +func createAPIAppWithName(t *testing.T, projectID string) (*app.CreateApplicationResponse, string) { + appName := gofakeit.AppName() + + reqForAPIAppCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appForAPIConfigChange, appAPIConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: projectID, + Name: appName, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + return appForAPIConfigChange, appName +} + +func createAPIApp(t *testing.T, projectID string) *app.CreateApplicationResponse { + res, _ := createAPIAppWithName(t, projectID) + return res +} + +func deactivateApp(t *testing.T, appToDeactivate *app.CreateApplicationResponse, projectID string) { + _, appDeactivateErr := instance.Client.AppV2Beta.DeactivateApplication(IAMOwnerCtx, &app.DeactivateApplicationRequest{ + ProjectId: projectID, + Id: appToDeactivate.GetAppId(), + }) + require.Nil(t, appDeactivateErr) +} + +func ensureFeaturePermissionV2Enabled(t *testing.T, instance *integration.Instance) { + ctx := instance.WithAuthorization(context.Background(), integration.UserTypeIAMOwner) + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + require.NoError(t, err) + + if f.PermissionCheckV2.GetEnabled() { + return + } + + _, err = instance.Client.FeatureV2.SetInstanceFeatures(ctx, &feature.SetInstanceFeaturesRequest{ + PermissionCheckV2: gu.Ptr(true), + }) + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, 5*time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{Inheritance: true}) + require.NoError(tt, err) + assert.True(tt, f.PermissionCheckV2.GetEnabled()) + }, retryDuration, tick, "timed out waiting for ensuring instance feature") +} diff --git a/internal/api/grpc/app/v2beta/query.go b/internal/api/grpc/app/v2beta/query.go new file mode 100644 index 0000000000..add8af83e6 --- /dev/null +++ b/internal/api/grpc/app/v2beta/query.go @@ -0,0 +1,37 @@ +package app + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func (s *Server) GetApplication(ctx context.Context, req *app.GetApplicationRequest) (*app.GetApplicationResponse, error) { + res, err := s.query.AppByIDWithPermission(ctx, req.GetId(), false, s.checkPermission) + if err != nil { + return nil, err + } + + return &app.GetApplicationResponse{ + App: convert.AppToPb(res), + }, nil +} + +func (s *Server) ListApplications(ctx context.Context, req *app.ListApplicationsRequest) (*app.ListApplicationsResponse, error) { + queries, err := convert.ListApplicationsRequestToModel(s.systemDefaults, req) + if err != nil { + return nil, err + } + + res, err := s.query.SearchApps(ctx, queries, s.checkPermission) + if err != nil { + return nil, err + } + + return &app.ListApplicationsResponse{ + Applications: convert.AppsToPb(res.Apps), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, res.SearchResponse), + }, nil +} diff --git a/internal/api/grpc/app/v2beta/server.go b/internal/api/grpc/app/v2beta/server.go new file mode 100644 index 0000000000..8343cbe404 --- /dev/null +++ b/internal/api/grpc/app/v2beta/server.go @@ -0,0 +1,57 @@ +package app + +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/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +var _ app.AppServiceServer = (*Server)(nil) + +type Server struct { + app.UnimplementedAppServiceServer + command *command.Commands + query *query.Queries + systemDefaults systemdefaults.SystemDefaults + 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) { + app.RegisterAppServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return app.AppService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return app.AppService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return app.AppService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return app.RegisterAppServiceHandler +} diff --git a/internal/api/grpc/filter/v2/converter.go b/internal/api/grpc/filter/v2/converter.go index 7a7d7cd8d7..f797ad4bba 100644 --- a/internal/api/grpc/filter/v2/converter.go +++ b/internal/api/grpc/filter/v2/converter.go @@ -48,3 +48,26 @@ func QueryToPaginationPb(request query.SearchRequest, response query.SearchRespo TotalResult: response.Count, } } + +func TextMethodPbToQuery(method filter.TextFilterMethod) query.TextComparison { + switch method { + case filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS: + return query.TextEquals + case filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS_IGNORE_CASE: + return query.TextEqualsIgnoreCase + case filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH: + return query.TextStartsWith + case filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH_IGNORE_CASE: + return query.TextStartsWithIgnoreCase + case filter.TextFilterMethod_TEXT_FILTER_METHOD_CONTAINS: + return query.TextContains + case filter.TextFilterMethod_TEXT_FILTER_METHOD_CONTAINS_IGNORE_CASE: + return query.TextContainsIgnoreCase + case filter.TextFilterMethod_TEXT_FILTER_METHOD_ENDS_WITH: + return query.TextEndsWith + case filter.TextFilterMethod_TEXT_FILTER_METHOD_ENDS_WITH_IGNORE_CASE: + return query.TextEndsWithIgnoreCase + default: + return -1 + } +} diff --git a/internal/api/grpc/management/project_application.go b/internal/api/grpc/management/project_application.go index ab49905409..a5526d3cb7 100644 --- a/internal/api/grpc/management/project_application.go +++ b/internal/api/grpc/management/project_application.go @@ -29,7 +29,7 @@ func (s *Server) ListApps(ctx context.Context, req *mgmt_pb.ListAppsRequest) (*m if err != nil { return nil, err } - apps, err := s.query.SearchApps(ctx, queries, false) + apps, err := s.query.SearchApps(ctx, queries, nil) if err != nil { return nil, err } @@ -125,7 +125,7 @@ func (s *Server) AddAPIApp(ctx context.Context, req *mgmt_pb.AddAPIAppRequest) ( } func (s *Server) UpdateApp(ctx context.Context, req *mgmt_pb.UpdateAppRequest) (*mgmt_pb.UpdateAppResponse, error) { - details, err := s.command.ChangeApplication(ctx, req.ProjectId, UpdateAppRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + details, err := s.command.UpdateApplicationName(ctx, req.ProjectId, UpdateAppRequestToDomain(req), authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -139,7 +139,7 @@ func (s *Server) UpdateOIDCAppConfig(ctx context.Context, req *mgmt_pb.UpdateOID if err != nil { return nil, err } - config, err := s.command.ChangeOIDCApplication(ctx, oidcApp, authz.GetCtxData(ctx).OrgID) + config, err := s.command.UpdateOIDCApplication(ctx, oidcApp, authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -157,7 +157,7 @@ func (s *Server) UpdateSAMLAppConfig(ctx context.Context, req *mgmt_pb.UpdateSAM if err != nil { return nil, err } - config, err := s.command.ChangeSAMLApplication(ctx, samlApp, authz.GetCtxData(ctx).OrgID) + config, err := s.command.UpdateSAMLApplication(ctx, samlApp, authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -171,7 +171,7 @@ func (s *Server) UpdateSAMLAppConfig(ctx context.Context, req *mgmt_pb.UpdateSAM } func (s *Server) UpdateAPIAppConfig(ctx context.Context, req *mgmt_pb.UpdateAPIAppConfigRequest) (*mgmt_pb.UpdateAPIAppConfigResponse, error) { - config, err := s.command.ChangeAPIApplication(ctx, UpdateAPIAppConfigRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + config, err := s.command.UpdateAPIApplication(ctx, UpdateAPIAppConfigRequestToDomain(req), authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/project_application_converter.go b/internal/api/grpc/management/project_application_converter.go index 13a0048a5b..186cedc933 100644 --- a/internal/api/grpc/management/project_application_converter.go +++ b/internal/api/grpc/management/project_application_converter.go @@ -4,6 +4,8 @@ import ( "context" "time" + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/api/authz" authn_grpc "github.com/zitadel/zitadel/internal/api/grpc/authn" "github.com/zitadel/zitadel/internal/api/grpc/object" @@ -46,24 +48,24 @@ func AddOIDCAppRequestToDomain(req *mgmt_pb.AddOIDCAppRequest) (*domain.OIDCApp, AggregateID: req.ProjectId, }, AppName: req.Name, - OIDCVersion: app_grpc.OIDCVersionToDomain(req.Version), + OIDCVersion: gu.Ptr(app_grpc.OIDCVersionToDomain(req.Version)), RedirectUris: req.RedirectUris, ResponseTypes: app_grpc.OIDCResponseTypesToDomain(req.ResponseTypes), GrantTypes: app_grpc.OIDCGrantTypesToDomain(req.GrantTypes), - ApplicationType: app_grpc.OIDCApplicationTypeToDomain(req.AppType), - AuthMethodType: app_grpc.OIDCAuthMethodTypeToDomain(req.AuthMethodType), + ApplicationType: gu.Ptr(app_grpc.OIDCApplicationTypeToDomain(req.AppType)), + AuthMethodType: gu.Ptr(app_grpc.OIDCAuthMethodTypeToDomain(req.AuthMethodType)), PostLogoutRedirectUris: req.PostLogoutRedirectUris, - DevMode: req.DevMode, - AccessTokenType: app_grpc.OIDCTokenTypeToDomain(req.AccessTokenType), - AccessTokenRoleAssertion: req.AccessTokenRoleAssertion, - IDTokenRoleAssertion: req.IdTokenRoleAssertion, - IDTokenUserinfoAssertion: req.IdTokenUserinfoAssertion, - ClockSkew: req.ClockSkew.AsDuration(), + DevMode: gu.Ptr(req.GetDevMode()), + AccessTokenType: gu.Ptr(app_grpc.OIDCTokenTypeToDomain(req.AccessTokenType)), + AccessTokenRoleAssertion: gu.Ptr(req.GetAccessTokenRoleAssertion()), + IDTokenRoleAssertion: gu.Ptr(req.GetIdTokenRoleAssertion()), + IDTokenUserinfoAssertion: gu.Ptr(req.GetIdTokenUserinfoAssertion()), + ClockSkew: gu.Ptr(req.GetClockSkew().AsDuration()), AdditionalOrigins: req.AdditionalOrigins, - SkipNativeAppSuccessPage: req.SkipNativeAppSuccessPage, - BackChannelLogoutURI: req.GetBackChannelLogoutUri(), - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + SkipNativeAppSuccessPage: gu.Ptr(req.GetSkipNativeAppSuccessPage()), + BackChannelLogoutURI: gu.Ptr(req.GetBackChannelLogoutUri()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } @@ -78,9 +80,9 @@ func AddSAMLAppRequestToDomain(req *mgmt_pb.AddSAMLAppRequest) (*domain.SAMLApp, }, AppName: req.Name, Metadata: req.GetMetadataXml(), - MetadataURL: req.GetMetadataUrl(), - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + MetadataURL: gu.Ptr(req.GetMetadataUrl()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } @@ -114,20 +116,20 @@ func UpdateOIDCAppConfigRequestToDomain(app *mgmt_pb.UpdateOIDCAppConfigRequest) RedirectUris: app.RedirectUris, ResponseTypes: app_grpc.OIDCResponseTypesToDomain(app.ResponseTypes), GrantTypes: app_grpc.OIDCGrantTypesToDomain(app.GrantTypes), - ApplicationType: app_grpc.OIDCApplicationTypeToDomain(app.AppType), - AuthMethodType: app_grpc.OIDCAuthMethodTypeToDomain(app.AuthMethodType), + ApplicationType: gu.Ptr(app_grpc.OIDCApplicationTypeToDomain(app.AppType)), + AuthMethodType: gu.Ptr(app_grpc.OIDCAuthMethodTypeToDomain(app.AuthMethodType)), PostLogoutRedirectUris: app.PostLogoutRedirectUris, - DevMode: app.DevMode, - AccessTokenType: app_grpc.OIDCTokenTypeToDomain(app.AccessTokenType), - AccessTokenRoleAssertion: app.AccessTokenRoleAssertion, - IDTokenRoleAssertion: app.IdTokenRoleAssertion, - IDTokenUserinfoAssertion: app.IdTokenUserinfoAssertion, - ClockSkew: app.ClockSkew.AsDuration(), + DevMode: gu.Ptr(app.GetDevMode()), + AccessTokenType: gu.Ptr(app_grpc.OIDCTokenTypeToDomain(app.AccessTokenType)), + AccessTokenRoleAssertion: gu.Ptr(app.GetAccessTokenRoleAssertion()), + IDTokenRoleAssertion: gu.Ptr(app.GetIdTokenRoleAssertion()), + IDTokenUserinfoAssertion: gu.Ptr(app.GetIdTokenUserinfoAssertion()), + ClockSkew: gu.Ptr(app.GetClockSkew().AsDuration()), AdditionalOrigins: app.AdditionalOrigins, - SkipNativeAppSuccessPage: app.SkipNativeAppSuccessPage, - BackChannelLogoutURI: app.BackChannelLogoutUri, - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + SkipNativeAppSuccessPage: gu.Ptr(app.GetSkipNativeAppSuccessPage()), + BackChannelLogoutURI: gu.Ptr(app.GetBackChannelLogoutUri()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } @@ -142,9 +144,9 @@ func UpdateSAMLAppConfigRequestToDomain(app *mgmt_pb.UpdateSAMLAppConfigRequest) }, AppID: app.AppId, Metadata: app.GetMetadataXml(), - MetadataURL: app.GetMetadataUrl(), - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + MetadataURL: gu.Ptr(app.GetMetadataUrl()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } diff --git a/internal/authz/repository/eventsourcing/view/application.go b/internal/authz/repository/eventsourcing/view/application.go index 8db8ec8e39..7fa920bcfe 100644 --- a/internal/authz/repository/eventsourcing/view/application.go +++ b/internal/authz/repository/eventsourcing/view/application.go @@ -32,7 +32,7 @@ func (v *View) ApplicationByProjecIDAndAppName(ctx context.Context, projectID, a }, } - apps, err := v.Query.SearchApps(ctx, queries, false) + apps, err := v.Query.SearchApps(ctx, queries, nil) if err != nil { return nil, err } diff --git a/internal/command/permission_checks.go b/internal/command/permission_checks.go index 6bfeaae219..3f978b6618 100644 --- a/internal/command/permission_checks.go +++ b/internal/command/permission_checks.go @@ -85,3 +85,11 @@ func (c *Commands) checkPermissionDeleteProjectGrant(ctx context.Context, resour } return nil } + +func (c *Commands) checkPermissionUpdateApplication(ctx context.Context, resourceOwner, appID string) error { + return c.newPermissionCheck(ctx, domain.PermissionProjectAppWrite, project.AggregateType)(resourceOwner, appID) +} + +func (c *Commands) checkPermissionDeleteApp(ctx context.Context, resourceOwner, appID string) error { + return c.newPermissionCheck(ctx, domain.PermissionProjectAppDelete, project.AggregateType)(resourceOwner, appID) +} diff --git a/internal/command/project_application.go b/internal/command/project_application.go index 0ccf5dc852..465b12e1e1 100644 --- a/internal/command/project_application.go +++ b/internal/command/project_application.go @@ -15,7 +15,7 @@ type AddApp struct { Name string } -func (c *Commands) ChangeApplication(ctx context.Context, projectID string, appChange domain.Application, resourceOwner string) (*domain.ObjectDetails, error) { +func (c *Commands) UpdateApplicationName(ctx context.Context, projectID string, appChange domain.Application, resourceOwner string) (*domain.ObjectDetails, error) { if projectID == "" || appChange.GetAppID() == "" || appChange.GetApplicationName() == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9vS", "Errors.Project.App.Invalid") } @@ -30,6 +30,13 @@ func (c *Commands) ChangeApplication(ctx context.Context, projectID string, appC if existingApp.Name == appChange.GetApplicationName() { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-2m8vx", "Errors.NoChangesFound") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingApp); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) pushedEvents, err := c.eventstore.Push( ctx, @@ -59,6 +66,13 @@ func (c *Commands) DeactivateApplication(ctx context.Context, projectID, appID, if existingApp.State != domain.AppStateActive { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-dsh35", "Errors.Project.App.NotActive") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingApp); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, project.NewApplicationDeactivatedEvent(ctx, projectAgg, appID)) if err != nil { @@ -86,6 +100,11 @@ func (c *Commands) ReactivateApplication(ctx context.Context, projectID, appID, if existingApp.State != domain.AppStateInactive { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-1n8cM", "Errors.Project.App.NotInactive") } + + if err := c.checkPermissionUpdateApplication(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, project.NewApplicationReactivatedEvent(ctx, projectAgg, appID)) @@ -111,6 +130,13 @@ func (c *Commands) RemoveApplication(ctx context.Context, projectID, appID, reso if existingApp.State == domain.AppStateUnspecified || existingApp.State == domain.AppStateRemoved { return nil, zerrors.ThrowNotFound(nil, "COMMAND-0po9s", "Errors.Project.App.NotExisting") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingApp); err != nil { + return nil, err + } + if err := c.checkPermissionDeleteApp(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) entityID := "" diff --git a/internal/command/project_application_api.go b/internal/command/project_application_api.go index 2832dcf873..82e7d0bde8 100644 --- a/internal/command/project_application_api.go +++ b/internal/command/project_application_api.go @@ -90,16 +90,24 @@ func (c *Commands) AddAPIApplication(ctx context.Context, apiApp *domain.APIApp, return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-5m9E", "Errors.Project.App.Invalid") } - if _, err := c.checkProjectExists(ctx, apiApp.AggregateID, resourceOwner); err != nil { + projectResOwner, err := c.checkProjectExists(ctx, apiApp.AggregateID, resourceOwner) + if err != nil { return nil, err } + if resourceOwner == "" { + resourceOwner = projectResOwner + } + if !apiApp.IsValid() { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-Bff2g", "Errors.Project.App.Invalid") } - appID, err := c.idGenerator.Next() - if err != nil { - return nil, err + appID := apiApp.AppID + if appID == "" { + appID, err = c.idGenerator.Next() + if err != nil { + return nil, err + } } return c.addAPIApplicationWithID(ctx, apiApp, resourceOwner, appID) @@ -112,6 +120,13 @@ func (c *Commands) addAPIApplicationWithID(ctx context.Context, apiApp *domain.A apiApp.AppID = appID addedApplication := NewAPIApplicationWriteModel(apiApp.AggregateID, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, addedApplication); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, addedApplication.ResourceOwner, addedApplication.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel) events := []eventstore.Command{ @@ -150,7 +165,7 @@ func (c *Commands) addAPIApplicationWithID(ctx context.Context, apiApp *domain.A return result, nil } -func (c *Commands) ChangeAPIApplication(ctx context.Context, apiApp *domain.APIApp, resourceOwner string) (*domain.APIApp, error) { +func (c *Commands) UpdateAPIApplication(ctx context.Context, apiApp *domain.APIApp, resourceOwner string) (*domain.APIApp, error) { if apiApp.AppID == "" || apiApp.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-1m900", "Errors.Project.App.APIConfigInvalid") } @@ -165,6 +180,13 @@ func (c *Commands) ChangeAPIApplication(ctx context.Context, apiApp *domain.APIA if !existingAPI.IsAPI() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Gnwt3", "Errors.Project.App.IsNotAPI") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingAPI); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingAPI.ResourceOwner, existingAPI.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingAPI.WriteModel) changedEvent, hasChanged, err := existingAPI.NewChangedEvent( ctx, @@ -205,6 +227,11 @@ func (c *Commands) ChangeAPIApplicationSecret(ctx context.Context, projectID, ap if !existingAPI.IsAPI() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-aeH4", "Errors.Project.App.IsNotAPI") } + + if err := c.checkPermissionUpdateApplication(ctx, existingAPI.ResourceOwner, existingAPI.AggregateID); err != nil { + return nil, err + } + encodedHash, plain, err := c.newHashedSecret(ctx, c.eventstore.Filter) //nolint:staticcheck if err != nil { return nil, err diff --git a/internal/command/project_application_api_test.go b/internal/command/project_application_api_test.go index a6d4349254..53448e1c5e 100644 --- a/internal/command/project_application_api_test.go +++ b/internal/command/project_application_api_test.go @@ -142,6 +142,7 @@ func TestAddAPIConfig(t *testing.T) { } func TestCommandSide_AddAPIApplication(t *testing.T) { + t.Parallel() type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator @@ -238,6 +239,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -292,6 +294,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -346,6 +349,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -390,6 +394,8 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, @@ -397,6 +403,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.AddAPIApplication(tt.args.ctx, tt.args.apiApp, tt.args.resourceOwner) if tt.res.err == nil { @@ -413,6 +420,8 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { } func TestCommandSide_ChangeAPIApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore } @@ -516,6 +525,7 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { domain.APIAuthMethodTypePrivateKeyJWT), ), ), + expectFilter(), ), }, args: args{ @@ -555,6 +565,7 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { domain.APIAuthMethodTypeBasic), ), ), + expectFilter(), expectPush( newAPIAppChangedEvent(context.Background(), "app1", @@ -593,14 +604,17 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), newHashedSecret: mockHashedSecret("secret"), defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeAPIApplication(tt.args.ctx, tt.args.apiApp, tt.args.resourceOwner) + got, err := r.UpdateAPIApplication(tt.args.ctx, tt.args.apiApp, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } @@ -615,6 +629,8 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { } func TestCommandSide_ChangeAPIApplicationSecret(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(*testing.T) *eventstore.Eventstore } @@ -734,12 +750,15 @@ func TestCommandSide_ChangeAPIApplicationSecret(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), newHashedSecret: mockHashedSecret("secret"), defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.ChangeAPIApplicationSecret(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { diff --git a/internal/command/project_application_oidc.go b/internal/command/project_application_oidc.go index 77ef7ff0c7..7f33b6a3cf 100644 --- a/internal/command/project_application_oidc.go +++ b/internal/command/project_application_oidc.go @@ -5,6 +5,8 @@ import ( "strings" "time" + "github.com/muhlemmer/gu" + http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/domain" @@ -120,6 +122,7 @@ func (c *Commands) AddOIDCAppCommand(app *addOIDCApp) preparation.Validation { } } +// TODO: Combine with AddOIDCApplication and addOIDCApplicationWithID func (c *Commands) AddOIDCApplicationWithID(ctx context.Context, oidcApp *domain.OIDCApp, resourceOwner, appID string) (_ *domain.OIDCApp, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -142,9 +145,15 @@ func (c *Commands) AddOIDCApplication(ctx context.Context, oidcApp *domain.OIDCA if oidcApp == nil || oidcApp.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-34Fm0", "Errors.Project.App.Invalid") } - if _, err := c.checkProjectExists(ctx, oidcApp.AggregateID, resourceOwner); err != nil { + + projectResOwner, err := c.checkProjectExists(ctx, oidcApp.AggregateID, resourceOwner) + if err != nil { return nil, err } + if resourceOwner == "" { + resourceOwner = projectResOwner + } + if oidcApp.AppName == "" || !oidcApp.IsValid() { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-1n8df", "Errors.Project.App.Invalid") } @@ -162,6 +171,13 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain defer func() { span.EndWithError(err) }() addedApplication := NewOIDCApplicationWriteModel(oidcApp.AggregateID, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, addedApplication); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, addedApplication.ResourceOwner, addedApplication.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel) oidcApp.AppID = appID @@ -183,27 +199,27 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain } events = append(events, project_repo.NewOIDCConfigAddedEvent(ctx, projectAgg, - oidcApp.OIDCVersion, + gu.Value(oidcApp.OIDCVersion), oidcApp.AppID, oidcApp.ClientID, oidcApp.EncodedHash, trimStringSliceWhiteSpaces(oidcApp.RedirectUris), oidcApp.ResponseTypes, oidcApp.GrantTypes, - oidcApp.ApplicationType, - oidcApp.AuthMethodType, + gu.Value(oidcApp.ApplicationType), + gu.Value(oidcApp.AuthMethodType), trimStringSliceWhiteSpaces(oidcApp.PostLogoutRedirectUris), - oidcApp.DevMode, - oidcApp.AccessTokenType, - oidcApp.AccessTokenRoleAssertion, - oidcApp.IDTokenRoleAssertion, - oidcApp.IDTokenUserinfoAssertion, - oidcApp.ClockSkew, + gu.Value(oidcApp.DevMode), + gu.Value(oidcApp.AccessTokenType), + gu.Value(oidcApp.AccessTokenRoleAssertion), + gu.Value(oidcApp.IDTokenRoleAssertion), + gu.Value(oidcApp.IDTokenUserinfoAssertion), + gu.Value(oidcApp.ClockSkew), trimStringSliceWhiteSpaces(oidcApp.AdditionalOrigins), - oidcApp.SkipNativeAppSuccessPage, - strings.TrimSpace(oidcApp.BackChannelLogoutURI), - oidcApp.LoginVersion, - strings.TrimSpace(oidcApp.LoginBaseURI), + gu.Value(oidcApp.SkipNativeAppSuccessPage), + strings.TrimSpace(gu.Value(oidcApp.BackChannelLogoutURI)), + gu.Value(oidcApp.LoginVersion), + strings.TrimSpace(gu.Value(oidcApp.LoginBaseURI)), )) addedApplication.AppID = oidcApp.AppID @@ -226,7 +242,7 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain return result, nil } -func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCApp, resourceOwner string) (*domain.OIDCApp, error) { +func (c *Commands) UpdateOIDCApplication(ctx context.Context, oidc *domain.OIDCApp, resourceOwner string) (*domain.OIDCApp, error) { if !oidc.IsValid() || oidc.AppID == "" || oidc.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-5m9fs", "Errors.Project.App.OIDCConfigInvalid") } @@ -241,7 +257,23 @@ func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCA if !existingOIDC.IsOIDC() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-GBr34", "Errors.Project.App.IsNotOIDC") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingOIDC); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingOIDC.ResourceOwner, existingOIDC.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingOIDC.WriteModel) + var backChannelLogout, loginBaseURI *string + if oidc.BackChannelLogoutURI != nil { + backChannelLogout = gu.Ptr(strings.TrimSpace(*oidc.BackChannelLogoutURI)) + } + + if oidc.LoginBaseURI != nil { + loginBaseURI = gu.Ptr(strings.TrimSpace(*oidc.LoginBaseURI)) + } + changedEvent, hasChanged, err := existingOIDC.NewChangedEvent( ctx, projectAgg, @@ -261,9 +293,9 @@ func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCA oidc.ClockSkew, trimStringSliceWhiteSpaces(oidc.AdditionalOrigins), oidc.SkipNativeAppSuccessPage, - strings.TrimSpace(oidc.BackChannelLogoutURI), + backChannelLogout, oidc.LoginVersion, - strings.TrimSpace(oidc.LoginBaseURI), + loginBaseURI, ) if err != nil { return nil, err @@ -301,6 +333,11 @@ func (c *Commands) ChangeOIDCApplicationSecret(ctx context.Context, projectID, a if !existingOIDC.IsOIDC() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Ghrh3", "Errors.Project.App.IsNotOIDC") } + + if err := c.checkPermissionUpdateApplication(ctx, existingOIDC.ResourceOwner, existingOIDC.AggregateID); err != nil { + return nil, err + } + encodedHash, plain, err := c.newHashedSecret(ctx, c.eventstore.Filter) //nolint:staticcheck if err != nil { return nil, err diff --git a/internal/command/project_application_oidc_model.go b/internal/command/project_application_oidc_model.go index 603ebdcda2..375bb26f5e 100644 --- a/internal/command/project_application_oidc_model.go +++ b/internal/command/project_application_oidc_model.go @@ -258,77 +258,77 @@ func (wm *OIDCApplicationWriteModel) NewChangedEvent( postLogoutRedirectURIs []string, responseTypes []domain.OIDCResponseType, grantTypes []domain.OIDCGrantType, - appType domain.OIDCApplicationType, - authMethodType domain.OIDCAuthMethodType, - oidcVersion domain.OIDCVersion, - accessTokenType domain.OIDCTokenType, + appType *domain.OIDCApplicationType, + authMethodType *domain.OIDCAuthMethodType, + oidcVersion *domain.OIDCVersion, + accessTokenType *domain.OIDCTokenType, devMode, accessTokenRoleAssertion, idTokenRoleAssertion, - idTokenUserinfoAssertion bool, - clockSkew time.Duration, + idTokenUserinfoAssertion *bool, + clockSkew *time.Duration, additionalOrigins []string, - skipNativeAppSuccessPage bool, - backChannelLogoutURI string, - loginVersion domain.LoginVersion, - loginBaseURI string, + skipNativeAppSuccessPage *bool, + backChannelLogoutURI *string, + loginVersion *domain.LoginVersion, + loginBaseURI *string, ) (*project.OIDCConfigChangedEvent, bool, error) { changes := make([]project.OIDCConfigChanges, 0) var err error - if !slices.Equal(wm.RedirectUris, redirectURIS) { + if redirectURIS != nil && !slices.Equal(wm.RedirectUris, redirectURIS) { changes = append(changes, project.ChangeRedirectURIs(redirectURIS)) } - if !slices.Equal(wm.ResponseTypes, responseTypes) { + if responseTypes != nil && !slices.Equal(wm.ResponseTypes, responseTypes) { changes = append(changes, project.ChangeResponseTypes(responseTypes)) } - if !slices.Equal(wm.GrantTypes, grantTypes) { + if grantTypes != nil && !slices.Equal(wm.GrantTypes, grantTypes) { changes = append(changes, project.ChangeGrantTypes(grantTypes)) } - if wm.ApplicationType != appType { - changes = append(changes, project.ChangeApplicationType(appType)) + if appType != nil && wm.ApplicationType != *appType { + changes = append(changes, project.ChangeApplicationType(*appType)) } - if wm.AuthMethodType != authMethodType { - changes = append(changes, project.ChangeAuthMethodType(authMethodType)) + if authMethodType != nil && wm.AuthMethodType != *authMethodType { + changes = append(changes, project.ChangeAuthMethodType(*authMethodType)) } - if !slices.Equal(wm.PostLogoutRedirectUris, postLogoutRedirectURIs) { + if postLogoutRedirectURIs != nil && !slices.Equal(wm.PostLogoutRedirectUris, postLogoutRedirectURIs) { changes = append(changes, project.ChangePostLogoutRedirectURIs(postLogoutRedirectURIs)) } - if wm.OIDCVersion != oidcVersion { - changes = append(changes, project.ChangeVersion(oidcVersion)) + if oidcVersion != nil && wm.OIDCVersion != *oidcVersion { + changes = append(changes, project.ChangeVersion(*oidcVersion)) } - if wm.DevMode != devMode { - changes = append(changes, project.ChangeDevMode(devMode)) + if devMode != nil && wm.DevMode != *devMode { + changes = append(changes, project.ChangeDevMode(*devMode)) } - if wm.AccessTokenType != accessTokenType { - changes = append(changes, project.ChangeAccessTokenType(accessTokenType)) + if accessTokenType != nil && wm.AccessTokenType != *accessTokenType { + changes = append(changes, project.ChangeAccessTokenType(*accessTokenType)) } - if wm.AccessTokenRoleAssertion != accessTokenRoleAssertion { - changes = append(changes, project.ChangeAccessTokenRoleAssertion(accessTokenRoleAssertion)) + if accessTokenRoleAssertion != nil && wm.AccessTokenRoleAssertion != *accessTokenRoleAssertion { + changes = append(changes, project.ChangeAccessTokenRoleAssertion(*accessTokenRoleAssertion)) } - if wm.IDTokenRoleAssertion != idTokenRoleAssertion { - changes = append(changes, project.ChangeIDTokenRoleAssertion(idTokenRoleAssertion)) + if idTokenRoleAssertion != nil && wm.IDTokenRoleAssertion != *idTokenRoleAssertion { + changes = append(changes, project.ChangeIDTokenRoleAssertion(*idTokenRoleAssertion)) } - if wm.IDTokenUserinfoAssertion != idTokenUserinfoAssertion { - changes = append(changes, project.ChangeIDTokenUserinfoAssertion(idTokenUserinfoAssertion)) + if idTokenUserinfoAssertion != nil && wm.IDTokenUserinfoAssertion != *idTokenUserinfoAssertion { + changes = append(changes, project.ChangeIDTokenUserinfoAssertion(*idTokenUserinfoAssertion)) } - if wm.ClockSkew != clockSkew { - changes = append(changes, project.ChangeClockSkew(clockSkew)) + if clockSkew != nil && wm.ClockSkew != *clockSkew { + changes = append(changes, project.ChangeClockSkew(*clockSkew)) } - if !slices.Equal(wm.AdditionalOrigins, additionalOrigins) { + if additionalOrigins != nil && !slices.Equal(wm.AdditionalOrigins, additionalOrigins) { changes = append(changes, project.ChangeAdditionalOrigins(additionalOrigins)) } - if wm.SkipNativeAppSuccessPage != skipNativeAppSuccessPage { - changes = append(changes, project.ChangeSkipNativeAppSuccessPage(skipNativeAppSuccessPage)) + if skipNativeAppSuccessPage != nil && wm.SkipNativeAppSuccessPage != *skipNativeAppSuccessPage { + changes = append(changes, project.ChangeSkipNativeAppSuccessPage(*skipNativeAppSuccessPage)) } - if wm.BackChannelLogoutURI != backChannelLogoutURI { - changes = append(changes, project.ChangeBackChannelLogoutURI(backChannelLogoutURI)) + if backChannelLogoutURI != nil && wm.BackChannelLogoutURI != *backChannelLogoutURI { + changes = append(changes, project.ChangeBackChannelLogoutURI(*backChannelLogoutURI)) } - if wm.LoginVersion != loginVersion { - changes = append(changes, project.ChangeOIDCLoginVersion(loginVersion)) + if loginVersion != nil && wm.LoginVersion != *loginVersion { + changes = append(changes, project.ChangeOIDCLoginVersion(*loginVersion)) } - if wm.LoginBaseURI != loginBaseURI { - changes = append(changes, project.ChangeOIDCLoginBaseURI(loginBaseURI)) + if loginBaseURI != nil && wm.LoginBaseURI != *loginBaseURI { + changes = append(changes, project.ChangeOIDCLoginBaseURI(*loginBaseURI)) } if len(changes) == 0 { diff --git a/internal/command/project_application_oidc_test.go b/internal/command/project_application_oidc_test.go index d0383b1b29..d728ffca45 100644 --- a/internal/command/project_application_oidc_test.go +++ b/internal/command/project_application_oidc_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/api/authz" @@ -401,6 +402,8 @@ func TestAddOIDCApp(t *testing.T) { } func TestCommandSide_AddOIDCApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator @@ -497,6 +500,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -538,24 +542,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{" https://test.ch "}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{" https://test.ch/logout "}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{" https://sub.test.ch "}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: " https://test.ch/backchannel ", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: " https://login.test.ch ", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr(" https://test.ch/backchannel "), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr(" https://login.test.ch "), }, resourceOwner: "org1", }, @@ -569,24 +573,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AppName: "app", ClientID: "client1", ClientSecretString: "secret", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), State: domain.AppStateActive, Compliance: &domain.Compliance{}, }, @@ -604,6 +608,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -645,24 +650,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), }, resourceOwner: "org1", }, @@ -676,24 +681,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AppName: "app", ClientID: "client1", ClientSecretString: "secret", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), State: domain.AppStateActive, Compliance: &domain.Compliance{}, }, @@ -702,6 +707,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() c := &Commands{ eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, @@ -709,6 +715,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } c.setMilestonesCompletedForTest("instanceID") got, err := c.AddOIDCApplication(tt.args.ctx, tt.args.oidcApp, tt.args.resourceOwner) @@ -726,6 +733,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { } func TestCommandSide_ChangeOIDCApplication(t *testing.T) { + t.Parallel() type fields struct { eventstore func(*testing.T) *eventstore.Eventstore } @@ -775,7 +783,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppID: "", - AuthMethodType: domain.OIDCAuthMethodTypePost, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, @@ -797,7 +805,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AggregateID: "", }, AppID: "appid", - AuthMethodType: domain.OIDCAuthMethodTypePost, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, @@ -821,7 +829,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppID: "app1", - AuthMethodType: domain.OIDCAuthMethodTypePost, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, @@ -870,6 +878,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ), ), ), + expectFilter(), ), }, args: args{ @@ -880,24 +889,24 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { }, AppID: "app1", AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), }, resourceOwner: "org1", }, @@ -944,6 +953,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ), ), ), + expectFilter(), ), }, args: args{ @@ -954,24 +964,24 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { }, AppID: "app1", AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch "}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{" https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{" https://sub.test.ch "}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: " https://test.ch/backchannel ", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: " https://login.test.ch ", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr(" https://test.ch/backchannel "), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr(" https://login.test.ch "), }, resourceOwner: "org1", }, @@ -980,7 +990,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { }, }, { - name: "change oidc app, ok", + name: "partial change oidc app, ok", fields: fields{ eventstore: expectEventstore( expectFilter( @@ -1018,6 +1028,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ), ), ), + expectFilter(), expectPush( newOIDCAppChangedEvent(context.Background(), "app1", @@ -1032,26 +1043,11 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, - AppID: "app1", - AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, - RedirectUris: []string{" https://test-change.ch "}, - ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, - GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, - PostLogoutRedirectUris: []string{" https://test-change.ch/logout "}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeJWT, - AccessTokenRoleAssertion: false, - IDTokenRoleAssertion: false, - IDTokenUserinfoAssertion: false, - ClockSkew: time.Second * 2, - AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + AppID: "app1", + AppName: "app", + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, resourceOwner: "org1", }, @@ -1064,24 +1060,24 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AppID: "app1", ClientID: "client1@project", AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, - RedirectUris: []string{"https://test-change.ch"}, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, - PostLogoutRedirectUris: []string{"https://test-change.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeJWT, - AccessTokenRoleAssertion: false, - IDTokenRoleAssertion: false, - IDTokenUserinfoAssertion: false, - ClockSkew: time.Second * 2, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + PostLogoutRedirectUris: []string{"https://test.ch/logout"}, + DevMode: gu.Ptr(false), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion1), + LoginBaseURI: gu.Ptr(""), Compliance: &domain.Compliance{}, State: domain.AppStateActive, }, @@ -1090,10 +1086,12 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // t.Parallel() r := &Commands{ - eventstore: tt.fields.eventstore(t), + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeOIDCApplication(tt.args.ctx, tt.args.oidcApp, tt.args.resourceOwner) + got, err := r.UpdateOIDCApplication(tt.args.ctx, tt.args.oidcApp, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } @@ -1108,6 +1106,8 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { } func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(*testing.T) *eventstore.Eventstore } @@ -1237,36 +1237,40 @@ func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) { AppName: "app", ClientID: "client1@project", ClientSecretString: "secret", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: false, - BackChannelLogoutURI: "", - LoginVersion: domain.LoginVersionUnspecified, + SkipNativeAppSuccessPage: gu.Ptr(false), + BackChannelLogoutURI: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), State: domain.AppStateActive, }, }, }, } for _, tt := range tests { - t.Run(tt.name, func(*testing.T) { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), newHashedSecret: mockHashedSecret("secret"), defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.ChangeOIDCApplicationSecret(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { @@ -1284,16 +1288,7 @@ func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) { func newOIDCAppChangedEvent(ctx context.Context, appID, projectID, resourceOwner string) *project.OIDCConfigChangedEvent { changes := []project.OIDCConfigChanges{ - project.ChangeRedirectURIs([]string{"https://test-change.ch"}), - project.ChangePostLogoutRedirectURIs([]string{"https://test-change.ch/logout"}), - project.ChangeDevMode(true), - project.ChangeAccessTokenType(domain.OIDCTokenTypeJWT), - project.ChangeAccessTokenRoleAssertion(false), - project.ChangeIDTokenRoleAssertion(false), - project.ChangeIDTokenUserinfoAssertion(false), - project.ChangeClockSkew(time.Second * 2), - project.ChangeOIDCLoginVersion(domain.LoginVersion2), - project.ChangeOIDCLoginBaseURI("https://login.test.ch"), + project.ChangeAuthMethodType(domain.OIDCAuthMethodTypeBasic), } event, _ := project.NewOIDCConfigChangedEvent(ctx, &project.NewAggregate(projectID, resourceOwner).Aggregate, diff --git a/internal/command/project_application_saml.go b/internal/command/project_application_saml.go index 1a5cefa221..9b1dc9e97a 100644 --- a/internal/command/project_application_saml.go +++ b/internal/command/project_application_saml.go @@ -3,6 +3,7 @@ package command import ( "context" + "github.com/muhlemmer/gu" "github.com/zitadel/saml/pkg/provider/xml" "github.com/zitadel/zitadel/internal/domain" @@ -16,10 +17,22 @@ func (c *Commands) AddSAMLApplication(ctx context.Context, application *domain.S return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-35Fn0", "Errors.Project.App.Invalid") } - if _, err := c.checkProjectExists(ctx, application.AggregateID, resourceOwner); err != nil { + projectResOwner, err := c.checkProjectExists(ctx, application.AggregateID, resourceOwner) + if err != nil { return nil, err } + if resourceOwner == "" { + resourceOwner = projectResOwner + } + addedApplication := NewSAMLApplicationWriteModel(application.AggregateID, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, addedApplication); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, addedApplication.ResourceOwner, addedApplication.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel) events, err := c.addSAMLApplication(ctx, projectAgg, application) if err != nil { @@ -49,12 +62,8 @@ func (c *Commands) addSAMLApplication(ctx context.Context, projectAgg *eventstor return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-1n9df", "Errors.Project.App.Invalid") } - if samlApp.Metadata == nil && samlApp.MetadataURL == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "SAML-podix9", "Errors.Project.App.SAMLMetadataMissing") - } - - if samlApp.MetadataURL != "" { - data, err := xml.ReadMetadataFromURL(c.httpClient, samlApp.MetadataURL) + if samlApp.MetadataURL != nil && *samlApp.MetadataURL != "" { + data, err := xml.ReadMetadataFromURL(c.httpClient, *samlApp.MetadataURL) if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "SAML-wmqlo1", "Errors.Project.App.SAMLMetadataMissing") } @@ -78,14 +87,14 @@ func (c *Commands) addSAMLApplication(ctx context.Context, projectAgg *eventstor samlApp.AppID, string(entity.EntityID), samlApp.Metadata, - samlApp.MetadataURL, - samlApp.LoginVersion, - samlApp.LoginBaseURI, + gu.Value(samlApp.MetadataURL), + gu.Value(samlApp.LoginVersion), + gu.Value(samlApp.LoginBaseURI), ), }, nil } -func (c *Commands) ChangeSAMLApplication(ctx context.Context, samlApp *domain.SAMLApp, resourceOwner string) (*domain.SAMLApp, error) { +func (c *Commands) UpdateSAMLApplication(ctx context.Context, samlApp *domain.SAMLApp, resourceOwner string) (*domain.SAMLApp, error) { if !samlApp.IsValid() || samlApp.AppID == "" || samlApp.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-5n9fs", "Errors.Project.App.SAMLConfigInvalid") } @@ -100,10 +109,15 @@ func (c *Commands) ChangeSAMLApplication(ctx context.Context, samlApp *domain.SA if !existingSAML.IsSAML() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-GBr35", "Errors.Project.App.IsNotSAML") } + + if err := c.checkPermissionUpdateApplication(ctx, existingSAML.ResourceOwner, existingSAML.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingSAML.WriteModel) - if samlApp.MetadataURL != "" { - data, err := xml.ReadMetadataFromURL(c.httpClient, samlApp.MetadataURL) + if samlApp.MetadataURL != nil && *samlApp.MetadataURL != "" { + data, err := xml.ReadMetadataFromURL(c.httpClient, *samlApp.MetadataURL) if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "SAML-J3kg3", "Errors.Project.App.SAMLMetadataMissing") } diff --git a/internal/command/project_application_saml_model.go b/internal/command/project_application_saml_model.go index f219039b58..f3097914f3 100644 --- a/internal/command/project_application_saml_model.go +++ b/internal/command/project_application_saml_model.go @@ -2,7 +2,7 @@ package command import ( "context" - "reflect" + "slices" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -170,26 +170,26 @@ func (wm *SAMLApplicationWriteModel) NewChangedEvent( appID string, entityID string, metadata []byte, - metadataURL string, - loginVersion domain.LoginVersion, - loginBaseURI string, + metadataURL *string, + loginVersion *domain.LoginVersion, + loginBaseURI *string, ) (*project.SAMLConfigChangedEvent, bool, error) { changes := make([]project.SAMLConfigChanges, 0) var err error - if !reflect.DeepEqual(wm.Metadata, metadata) { + if metadata != nil && !slices.Equal(wm.Metadata, metadata) { changes = append(changes, project.ChangeMetadata(metadata)) } - if wm.MetadataURL != metadataURL { - changes = append(changes, project.ChangeMetadataURL(metadataURL)) + if metadataURL != nil && wm.MetadataURL != *metadataURL { + changes = append(changes, project.ChangeMetadataURL(*metadataURL)) } if wm.EntityID != entityID { changes = append(changes, project.ChangeEntityID(entityID)) } - if wm.LoginVersion != loginVersion { - changes = append(changes, project.ChangeSAMLLoginVersion(loginVersion)) + if loginVersion != nil && wm.LoginVersion != *loginVersion { + changes = append(changes, project.ChangeSAMLLoginVersion(*loginVersion)) } - if wm.LoginBaseURI != loginBaseURI { - changes = append(changes, project.ChangeSAMLLoginBaseURI(loginBaseURI)) + if loginBaseURI != nil && wm.LoginBaseURI != *loginBaseURI { + changes = append(changes, project.ChangeSAMLLoginBaseURI(*loginBaseURI)) } if len(changes) == 0 { diff --git a/internal/command/project_application_saml_test.go b/internal/command/project_application_saml_test.go index c6f6f7cf21..5d18d9587c 100644 --- a/internal/command/project_application_saml_test.go +++ b/internal/command/project_application_saml_test.go @@ -7,6 +7,7 @@ import ( "net/http" "testing" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/api/authz" @@ -49,6 +50,8 @@ var testMetadataChangedEntityID = []byte(` `) func TestCommandSide_AddSAMLApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator @@ -117,6 +120,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), ), }, args: args{ @@ -134,6 +138,37 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { err: zerrors.IsErrorInvalidArgument, }, }, + { + name: "empty metas, invalid argument error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingUnspecified), + ), + ), + expectFilter(), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlApp: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, + AppName: "app", + EntityID: "https://test.com/saml/metadata", + }, + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, { name: "create saml app, metadata not parsable", fields: fields{ @@ -146,6 +181,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t), }, @@ -158,7 +194,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: []byte("test metadata"), - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -178,6 +214,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -206,7 +243,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -216,12 +253,14 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test.com/saml/metadata", - Metadata: testMetadata, - MetadataURL: "", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test.com/saml/metadata", + Metadata: testMetadata, + MetadataURL: gu.Ptr(""), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, }, @@ -237,6 +276,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -265,9 +305,9 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, resourceOwner: "org1", }, @@ -281,10 +321,10 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", + MetadataURL: gu.Ptr(""), State: domain.AppStateActive, - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, }, }, @@ -300,6 +340,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -329,7 +370,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -339,12 +380,14 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test.com/saml/metadata", - Metadata: testMetadata, - MetadataURL: "http://localhost:8080/saml/metadata", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test.com/saml/metadata", + Metadata: testMetadata, + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, }, @@ -360,6 +403,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t), httpClient: newTestClient(http.StatusNotFound, nil), @@ -373,7 +417,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -385,10 +429,13 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := &Commands{ - eventstore: tt.fields.eventstore(t), - idGenerator: tt.fields.idGenerator, - httpClient: tt.fields.httpClient, + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + httpClient: tt.fields.httpClient, + checkPermission: newMockPermissionCheckAllowed(), } c.setMilestonesCompletedForTest("instanceID") got, err := c.AddSAMLApplication(tt.args.ctx, tt.args.samlApp, tt.args.resourceOwner) @@ -406,6 +453,8 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { } func TestCommandSide_ChangeSAMLApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore httpClient *http.Client @@ -544,7 +593,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppID: "app1", EntityID: "https://test.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -590,7 +639,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppID: "app1", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -646,7 +695,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -656,17 +705,19 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test2.com/saml/metadata", - Metadata: testMetadataChangedEntityID, - MetadataURL: "http://localhost:8080/saml/metadata", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test2.com/saml/metadata", + Metadata: testMetadataChangedEntityID, + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, }, { - name: "change saml app, ok, metadata", + name: "partial change saml app, ok, metadata", fields: fields{ eventstore: expectEventstore( expectFilter( @@ -713,7 +764,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: testMetadataChangedEntityID, - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -723,15 +774,18 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test2.com/saml/metadata", - Metadata: testMetadataChangedEntityID, - MetadataURL: "", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test2.com/saml/metadata", + Metadata: testMetadataChangedEntityID, + MetadataURL: gu.Ptr(""), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, - }, { + }, + { name: "change saml app, ok, loginversion", fields: fields{ eventstore: expectEventstore( @@ -781,9 +835,9 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: testMetadataChangedEntityID, - MetadataURL: "", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, resourceOwner: "org1", }, @@ -797,10 +851,10 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: testMetadataChangedEntityID, - MetadataURL: "", + MetadataURL: gu.Ptr(""), State: domain.AppStateActive, - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, }, }, @@ -808,11 +862,14 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore(t), - httpClient: tt.fields.httpClient, + eventstore: tt.fields.eventstore(t), + httpClient: tt.fields.httpClient, + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeSAMLApplication(tt.args.ctx, tt.args.samlApp, tt.args.resourceOwner) + got, err := r.UpdateSAMLApplication(tt.args.ctx, tt.args.samlApp, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/project_application_test.go b/internal/command/project_application_test.go index 050a41d29f..a67e6886ed 100644 --- a/internal/command/project_application_test.go +++ b/internal/command/project_application_test.go @@ -8,13 +8,16 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository/mock" "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/zerrors" ) func TestCommandSide_ChangeApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -35,9 +38,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "invalid app missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -55,9 +56,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "invalid app missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -74,9 +73,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "invalid app missing name, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -94,10 +91,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), + eventstore: expectEventstore(expectFilter()), }, args: args{ ctx: context.Background(), @@ -115,8 +109,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "app name not changed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -142,8 +135,14 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "app changed, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + )), + ), expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -179,10 +178,13 @@ func TestCommandSide_ChangeApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeApplication(tt.args.ctx, tt.args.projectID, tt.args.app, tt.args.resourceOwner) + got, err := r.UpdateApplicationName(tt.args.ctx, tt.args.projectID, tt.args.app, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } @@ -197,8 +199,10 @@ func TestCommandSide_ChangeApplication(t *testing.T) { } func TestCommandSide_DeactivateApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -219,9 +223,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -236,9 +238,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -253,8 +253,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -271,8 +270,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "app already inactive, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -299,8 +297,14 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "app deactivate, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + )), + ), expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -331,8 +335,11 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.DeactivateApplication(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { @@ -349,8 +356,10 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { } func TestCommandSide_ReactivateApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -371,9 +380,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -388,9 +395,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -405,10 +410,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), + eventstore: expectEventstore(expectFilter()), }, args: args{ ctx: context.Background(), @@ -423,8 +425,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "app already active, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -447,8 +448,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "app reactivate, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -483,8 +483,11 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.ReactivateApplication(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { @@ -501,8 +504,10 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { } func TestCommandSide_RemoveApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -523,9 +528,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -540,9 +543,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -557,10 +558,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), + eventstore: expectEventstore(expectFilter()), }, args: args{ ctx: context.Background(), @@ -575,8 +573,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "app remove, entityID, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -584,6 +581,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { "app", )), ), + expectFilter(), expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -625,8 +623,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "app remove, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -636,6 +633,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { ), // app is not saml, or no saml config available expectFilter(), + expectFilter(), expectPush( project.NewApplicationRemovedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -661,8 +659,11 @@ func TestCommandSide_RemoveApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.RemoveApplication(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { diff --git a/internal/command/project_converter.go b/internal/command/project_converter.go index 01b5a4e63d..e88a1cb75a 100644 --- a/internal/command/project_converter.go +++ b/internal/command/project_converter.go @@ -1,6 +1,8 @@ package command import ( + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/domain" ) @@ -35,21 +37,21 @@ func oidcWriteModelToOIDCConfig(writeModel *OIDCApplicationWriteModel) *domain.O RedirectUris: writeModel.RedirectUris, ResponseTypes: writeModel.ResponseTypes, GrantTypes: writeModel.GrantTypes, - ApplicationType: writeModel.ApplicationType, - AuthMethodType: writeModel.AuthMethodType, + ApplicationType: gu.Ptr(writeModel.ApplicationType), + AuthMethodType: gu.Ptr(writeModel.AuthMethodType), PostLogoutRedirectUris: writeModel.PostLogoutRedirectUris, - OIDCVersion: writeModel.OIDCVersion, - DevMode: writeModel.DevMode, - AccessTokenType: writeModel.AccessTokenType, - AccessTokenRoleAssertion: writeModel.AccessTokenRoleAssertion, - IDTokenRoleAssertion: writeModel.IDTokenRoleAssertion, - IDTokenUserinfoAssertion: writeModel.IDTokenUserinfoAssertion, - ClockSkew: writeModel.ClockSkew, + OIDCVersion: gu.Ptr(writeModel.OIDCVersion), + DevMode: gu.Ptr(writeModel.DevMode), + AccessTokenType: gu.Ptr(writeModel.AccessTokenType), + AccessTokenRoleAssertion: gu.Ptr(writeModel.AccessTokenRoleAssertion), + IDTokenRoleAssertion: gu.Ptr(writeModel.IDTokenRoleAssertion), + IDTokenUserinfoAssertion: gu.Ptr(writeModel.IDTokenUserinfoAssertion), + ClockSkew: gu.Ptr(writeModel.ClockSkew), AdditionalOrigins: writeModel.AdditionalOrigins, - SkipNativeAppSuccessPage: writeModel.SkipNativeAppSuccessPage, - BackChannelLogoutURI: writeModel.BackChannelLogoutURI, - LoginVersion: writeModel.LoginVersion, - LoginBaseURI: writeModel.LoginBaseURI, + SkipNativeAppSuccessPage: gu.Ptr(writeModel.SkipNativeAppSuccessPage), + BackChannelLogoutURI: gu.Ptr(writeModel.BackChannelLogoutURI), + LoginVersion: gu.Ptr(writeModel.LoginVersion), + LoginBaseURI: gu.Ptr(writeModel.LoginBaseURI), } } @@ -60,10 +62,10 @@ func samlWriteModelToSAMLConfig(writeModel *SAMLApplicationWriteModel) *domain.S AppName: writeModel.AppName, State: writeModel.State, Metadata: writeModel.Metadata, - MetadataURL: writeModel.MetadataURL, + MetadataURL: gu.Ptr(writeModel.MetadataURL), EntityID: writeModel.EntityID, - LoginVersion: writeModel.LoginVersion, - LoginBaseURI: writeModel.LoginBaseURI, + LoginVersion: gu.Ptr(writeModel.LoginVersion), + LoginBaseURI: gu.Ptr(writeModel.LoginBaseURI), } } @@ -78,15 +80,6 @@ func apiWriteModelToAPIConfig(writeModel *APIApplicationWriteModel) *domain.APIA } } -func roleWriteModelToRole(writeModel *ProjectRoleWriteModel) *domain.ProjectRole { - return &domain.ProjectRole{ - ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel), - Key: writeModel.Key, - DisplayName: writeModel.DisplayName, - Group: writeModel.Group, - } -} - func memberWriteModelToProjectGrantMember(writeModel *ProjectGrantMemberWriteModel) *domain.ProjectGrantMember { return &domain.ProjectGrantMember{ ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel), diff --git a/internal/command/project_model.go b/internal/command/project_model.go index cabceb8500..4c9496b3ad 100644 --- a/internal/command/project_model.go +++ b/internal/command/project_model.go @@ -2,6 +2,7 @@ package command import ( "context" + "slices" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -120,7 +121,7 @@ func (wm *ProjectWriteModel) NewChangedEvent( } func isProjectStateExists(state domain.ProjectState) bool { - return !hasProjectState(state, domain.ProjectStateRemoved, domain.ProjectStateUnspecified) + return !slices.Contains([]domain.ProjectState{domain.ProjectStateRemoved, domain.ProjectStateUnspecified}, state) } func ProjectAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate { @@ -130,12 +131,3 @@ func ProjectAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggre func ProjectAggregateFromWriteModelWithCTX(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate { return project.AggregateFromWriteModel(ctx, wm) } - -func hasProjectState(check domain.ProjectState, states ...domain.ProjectState) bool { - for _, state := range states { - if check == state { - return true - } - } - return false -} diff --git a/internal/domain/application_oidc.go b/internal/domain/application_oidc.go index 5d466c689d..10a70a1776 100644 --- a/internal/domain/application_oidc.go +++ b/internal/domain/application_oidc.go @@ -1,6 +1,7 @@ package domain import ( + "slices" "strings" "time" @@ -32,22 +33,22 @@ type OIDCApp struct { RedirectUris []string ResponseTypes []OIDCResponseType GrantTypes []OIDCGrantType - ApplicationType OIDCApplicationType - AuthMethodType OIDCAuthMethodType + ApplicationType *OIDCApplicationType + AuthMethodType *OIDCAuthMethodType PostLogoutRedirectUris []string - OIDCVersion OIDCVersion + OIDCVersion *OIDCVersion Compliance *Compliance - DevMode bool - AccessTokenType OIDCTokenType - AccessTokenRoleAssertion bool - IDTokenRoleAssertion bool - IDTokenUserinfoAssertion bool - ClockSkew time.Duration + DevMode *bool + AccessTokenType *OIDCTokenType + AccessTokenRoleAssertion *bool + IDTokenRoleAssertion *bool + IDTokenUserinfoAssertion *bool + ClockSkew *time.Duration AdditionalOrigins []string - SkipNativeAppSuccessPage bool - BackChannelLogoutURI string - LoginVersion LoginVersion - LoginBaseURI string + SkipNativeAppSuccessPage *bool + BackChannelLogoutURI *string + LoginVersion *LoginVersion + LoginBaseURI *string State AppState } @@ -69,7 +70,7 @@ func (a *OIDCApp) setClientSecret(encodedHash string) { } func (a *OIDCApp) requiresClientSecret() bool { - return a.AuthMethodType == OIDCAuthMethodTypeBasic || a.AuthMethodType == OIDCAuthMethodTypePost + return a.AuthMethodType != nil && (*a.AuthMethodType == OIDCAuthMethodTypeBasic || *a.AuthMethodType == OIDCAuthMethodTypePost) } type OIDCVersion int32 @@ -137,7 +138,7 @@ const ( ) func (a *OIDCApp) IsValid() bool { - if a.ClockSkew > time.Second*5 || a.ClockSkew < time.Second*0 || !a.OriginsValid() { + if (a.ClockSkew != nil && (*a.ClockSkew > time.Second*5 || *a.ClockSkew < time.Second*0)) || !a.OriginsValid() { return false } grantTypes := a.getRequiredGrantTypes() @@ -204,30 +205,25 @@ func ContainsOIDCGrantTypes(shouldContain, list []OIDCGrantType) bool { } func containsOIDCGrantType(grantTypes []OIDCGrantType, grantType OIDCGrantType) bool { - for _, gt := range grantTypes { - if gt == grantType { - return true - } - } - return false + return slices.Contains(grantTypes, grantType) } func (a *OIDCApp) FillCompliance() { a.Compliance = GetOIDCCompliance(a.OIDCVersion, a.ApplicationType, a.GrantTypes, a.ResponseTypes, a.AuthMethodType, a.RedirectUris) } -func GetOIDCCompliance(version OIDCVersion, appType OIDCApplicationType, grantTypes []OIDCGrantType, responseTypes []OIDCResponseType, authMethod OIDCAuthMethodType, redirectUris []string) *Compliance { - switch version { - case OIDCVersionV1: +func GetOIDCCompliance(version *OIDCVersion, appType *OIDCApplicationType, grantTypes []OIDCGrantType, responseTypes []OIDCResponseType, authMethod *OIDCAuthMethodType, redirectUris []string) *Compliance { + if version != nil && *version == OIDCVersionV1 { return GetOIDCV1Compliance(appType, grantTypes, authMethod, redirectUris) } + return &Compliance{ NoneCompliant: true, Problems: []string{"Application.OIDC.UnsupportedVersion"}, } } -func GetOIDCV1Compliance(appType OIDCApplicationType, grantTypes []OIDCGrantType, authMethod OIDCAuthMethodType, redirectUris []string) *Compliance { +func GetOIDCV1Compliance(appType *OIDCApplicationType, grantTypes []OIDCGrantType, authMethod *OIDCAuthMethodType, redirectUris []string) *Compliance { compliance := &Compliance{NoneCompliant: false} checkGrantTypesCombination(compliance, grantTypes) @@ -247,7 +243,7 @@ func checkGrantTypesCombination(compliance *Compliance, grantTypes []OIDCGrantTy } } -func checkRedirectURIs(compliance *Compliance, grantTypes []OIDCGrantType, appType OIDCApplicationType, redirectUris []string) { +func checkRedirectURIs(compliance *Compliance, grantTypes []OIDCGrantType, appType *OIDCApplicationType, redirectUris []string) { // See #5684 for OIDCGrantTypeDeviceCode and redirectUris further explanation if len(redirectUris) == 0 && (!containsOIDCGrantType(grantTypes, OIDCGrantTypeDeviceCode) || (containsOIDCGrantType(grantTypes, OIDCGrantTypeDeviceCode) && containsOIDCGrantType(grantTypes, OIDCGrantTypeAuthorizationCode))) { compliance.NoneCompliant = true @@ -266,53 +262,58 @@ func checkRedirectURIs(compliance *Compliance, grantTypes []OIDCGrantType, appTy } } -func checkApplicationType(compliance *Compliance, appType OIDCApplicationType, authMethod OIDCAuthMethodType) { - switch appType { - case OIDCApplicationTypeNative: - GetOIDCV1NativeApplicationCompliance(compliance, authMethod) - case OIDCApplicationTypeUserAgent: - GetOIDCV1UserAgentApplicationCompliance(compliance, authMethod) +func checkApplicationType(compliance *Compliance, appType *OIDCApplicationType, authMethod *OIDCAuthMethodType) { + if appType != nil { + switch *appType { + case OIDCApplicationTypeNative: + GetOIDCV1NativeApplicationCompliance(compliance, authMethod) + case OIDCApplicationTypeUserAgent: + GetOIDCV1UserAgentApplicationCompliance(compliance, authMethod) + case OIDCApplicationTypeWeb: + return + } } + if compliance.NoneCompliant { compliance.Problems = append([]string{"Application.OIDC.V1.NotCompliant"}, compliance.Problems...) } } -func GetOIDCV1NativeApplicationCompliance(compliance *Compliance, authMethod OIDCAuthMethodType) { - if authMethod != OIDCAuthMethodTypeNone { +func GetOIDCV1NativeApplicationCompliance(compliance *Compliance, authMethod *OIDCAuthMethodType) { + if authMethod != nil && *authMethod != OIDCAuthMethodTypeNone { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.AuthMethodType.NotNone") } } -func GetOIDCV1UserAgentApplicationCompliance(compliance *Compliance, authMethod OIDCAuthMethodType) { - if authMethod != OIDCAuthMethodTypeNone { +func GetOIDCV1UserAgentApplicationCompliance(compliance *Compliance, authMethod *OIDCAuthMethodType) { + if authMethod != nil && *authMethod != OIDCAuthMethodTypeNone { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.UserAgent.AuthMethodType.NotNone") } } -func CheckRedirectUrisCode(compliance *Compliance, appType OIDCApplicationType, redirectUris []string) { +func CheckRedirectUrisCode(compliance *Compliance, appType *OIDCApplicationType, redirectUris []string) { if urlsAreHttps(redirectUris) { return } if urlContainsPrefix(redirectUris, http) { - if appType == OIDCApplicationTypeUserAgent { + if appType != nil && *appType == OIDCApplicationTypeUserAgent { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.HttpOnlyForWeb") } - if appType == OIDCApplicationTypeNative && !onlyLocalhostIsHttp(redirectUris) { + if appType != nil && *appType == OIDCApplicationTypeNative && !onlyLocalhostIsHttp(redirectUris) { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.RedirectUris.MustBeHttpLocalhost") } } - if containsCustom(redirectUris) && appType != OIDCApplicationTypeNative { + if containsCustom(redirectUris) && appType != nil && *appType != OIDCApplicationTypeNative { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.CustomOnlyForNative") } } -func CheckRedirectUrisImplicit(compliance *Compliance, appType OIDCApplicationType, redirectUris []string) { +func CheckRedirectUrisImplicit(compliance *Compliance, appType *OIDCApplicationType, redirectUris []string) { if urlsAreHttps(redirectUris) { return } @@ -321,7 +322,7 @@ func CheckRedirectUrisImplicit(compliance *Compliance, appType OIDCApplicationTy compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed") } if urlContainsPrefix(redirectUris, http) { - if appType == OIDCApplicationTypeNative { + if appType != nil && *appType == OIDCApplicationTypeNative { if !onlyLocalhostIsHttp(redirectUris) { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.RedirectUris.MustBeHttpLocalhost") @@ -333,20 +334,20 @@ func CheckRedirectUrisImplicit(compliance *Compliance, appType OIDCApplicationTy } } -func CheckRedirectUrisImplicitAndCode(compliance *Compliance, appType OIDCApplicationType, redirectUris []string) { +func CheckRedirectUrisImplicitAndCode(compliance *Compliance, appType *OIDCApplicationType, redirectUris []string) { if urlsAreHttps(redirectUris) { return } - if containsCustom(redirectUris) && appType != OIDCApplicationTypeNative { + if containsCustom(redirectUris) && appType != nil && *appType != OIDCApplicationTypeNative { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed") } if urlContainsPrefix(redirectUris, http) { - if appType == OIDCApplicationTypeUserAgent { + if appType != nil && *appType == OIDCApplicationTypeUserAgent { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.HttpOnlyForWeb") } - if !onlyLocalhostIsHttp(redirectUris) && appType == OIDCApplicationTypeNative { + if !onlyLocalhostIsHttp(redirectUris) && appType != nil && *appType == OIDCApplicationTypeNative { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.RedirectUris.MustBeHttpLocalhost") } diff --git a/internal/domain/application_oidc_test.go b/internal/domain/application_oidc_test.go index b3d9488827..4208917cdd 100644 --- a/internal/domain/application_oidc_test.go +++ b/internal/domain/application_oidc_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" ) @@ -25,7 +27,7 @@ func TestApplicationValid(t *testing.T) { ObjectRoot: models.ObjectRoot{AggregateID: "AggregateID"}, AppID: "AppID", AppName: "AppName", - ClockSkew: time.Minute * 1, + ClockSkew: gu.Ptr(time.Minute * 1), ResponseTypes: []OIDCResponseType{OIDCResponseTypeCode}, GrantTypes: []OIDCGrantType{OIDCGrantTypeAuthorizationCode}, }, @@ -39,7 +41,7 @@ func TestApplicationValid(t *testing.T) { ObjectRoot: models.ObjectRoot{AggregateID: "AggregateID"}, AppID: "AppID", AppName: "AppName", - ClockSkew: time.Minute * -1, + ClockSkew: gu.Ptr(time.Minute * -1), ResponseTypes: []OIDCResponseType{OIDCResponseTypeCode}, GrantTypes: []OIDCGrantType{OIDCGrantTypeAuthorizationCode}, }, @@ -190,9 +192,9 @@ func TestApplicationValid(t *testing.T) { func TestGetOIDCV1Compliance(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType grantTypes []OIDCGrantType - authMethod OIDCAuthMethodType + authMethod *OIDCAuthMethodType redirectUris []string } tests := []struct { @@ -266,7 +268,7 @@ func Test_checkGrantTypesCombination(t *testing.T) { func Test_checkRedirectURIs(t *testing.T) { type args struct { grantTypes []OIDCGrantType - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -304,7 +306,7 @@ func Test_checkRedirectURIs(t *testing.T) { args: args{ redirectUris: []string{"http://redirect.to/me"}, grantTypes: []OIDCGrantType{OIDCGrantTypeImplicit}, - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), }, }, { @@ -316,7 +318,7 @@ func Test_checkRedirectURIs(t *testing.T) { args: args{ redirectUris: []string{"http://redirect.to/me"}, grantTypes: []OIDCGrantType{OIDCGrantTypeAuthorizationCode}, - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), }, }, } @@ -338,7 +340,7 @@ func Test_checkRedirectURIs(t *testing.T) { func Test_CheckRedirectUrisImplicitAndCode(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -356,17 +358,6 @@ func Test_CheckRedirectUrisImplicitAndCode(t *testing.T) { redirectUris: []string{"https://redirect.to/me"}, }, }, - // { - // name: "custom protocol, not native", - // want: &Compliance{ - // NoneCompliant: true, - // Problems: []string{"Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed"}, - // }, - // args: args{ - // redirectUris: []string{"protocol://redirect.to/me"}, - // appType: OIDCApplicationTypeWeb, - // }, - // }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -386,7 +377,7 @@ func Test_CheckRedirectUrisImplicitAndCode(t *testing.T) { func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -402,7 +393,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "custom protocol not native app", args: args{ - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), redirectUris: []string{"custom://nirvana.com"}, }, want: &Compliance{ @@ -413,7 +404,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "http localhost user agent app", args: args{ - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), redirectUris: []string{"http://localhost:9009"}, }, want: &Compliance{ @@ -424,7 +415,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "http, not only localhost native app", args: args{ - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), redirectUris: []string{"http://nirvana.com", "http://localhost:9009"}, }, want: &Compliance{ @@ -435,7 +426,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "not allowed combination", args: args{ - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), redirectUris: []string{"https://nirvana.com", "cutom://nirvana.com"}, }, want: &Compliance{ @@ -461,7 +452,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { func TestCheckRedirectUrisImplicit(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -488,7 +479,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { name: "only http protocol, app type native, not only localhost", args: args{ redirectUris: []string{"http://nirvana.com"}, - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), }, want: &Compliance{ NoneCompliant: true, @@ -499,7 +490,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { name: "only http protocol, app type native, only localhost", args: args{ redirectUris: []string{"http://localhost:8080"}, - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), }, want: &Compliance{ NoneCompliant: false, @@ -510,7 +501,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { name: "only http protocol, app type web", args: args{ redirectUris: []string{"http://nirvana.com"}, - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), }, want: &Compliance{ NoneCompliant: true, @@ -535,7 +526,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { func TestCheckRedirectUrisCode(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -552,7 +543,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "custom prefix, app type web", args: args{ redirectUris: []string{"custom://nirvana.com"}, - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), }, want: &Compliance{ NoneCompliant: true, @@ -563,7 +554,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "only http protocol, app type user agent", args: args{ redirectUris: []string{"http://nirvana.com"}, - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), }, want: &Compliance{ NoneCompliant: true, @@ -574,7 +565,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "only http protocol, app type native, only localhost", args: args{ redirectUris: []string{"http://localhost:8080", "http://nirvana.com:8080"}, - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), }, want: &Compliance{ NoneCompliant: true, @@ -585,7 +576,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "custom protocol, not native", args: args{ redirectUris: []string{"custom://nirvana.com"}, - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), }, want: &Compliance{ NoneCompliant: true, diff --git a/internal/domain/application_saml.go b/internal/domain/application_saml.go index de7ef789ee..aff1875c7e 100644 --- a/internal/domain/application_saml.go +++ b/internal/domain/application_saml.go @@ -11,9 +11,9 @@ type SAMLApp struct { AppName string EntityID string Metadata []byte - MetadataURL string - LoginVersion LoginVersion - LoginBaseURI string + MetadataURL *string + LoginVersion *LoginVersion + LoginBaseURI *string State AppState } @@ -31,11 +31,14 @@ func (a *SAMLApp) GetMetadata() []byte { } func (a *SAMLApp) GetMetadataURL() string { - return a.MetadataURL + if a.MetadataURL != nil { + return *a.MetadataURL + } + return "" } func (a *SAMLApp) IsValid() bool { - if a.MetadataURL == "" && a.Metadata == nil { + if (a.MetadataURL == nil || *a.MetadataURL == "") && a.Metadata == nil { return false } return true diff --git a/internal/domain/permission.go b/internal/domain/permission.go index bb569955f5..119e8c2d3e 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -47,6 +47,9 @@ const ( PermissionProjectRoleWrite = "project.role.write" PermissionProjectRoleRead = "project.role.read" PermissionProjectRoleDelete = "project.role.delete" + PermissionProjectAppWrite = "project.app.write" + PermissionProjectAppDelete = "project.app.delete" + PermissionProjectAppRead = "project.app.read" ) // ProjectPermissionCheck is used as a check for preconditions dependent on application, project, user resourceowner and usergrants. diff --git a/internal/integration/client.go b/internal/integration/client.go index 20c98b5628..326d6fa8b4 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -22,6 +22,7 @@ import ( "github.com/zitadel/zitadel/internal/integration/scim" action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" "github.com/zitadel/zitadel/pkg/grpc/admin" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" "github.com/zitadel/zitadel/pkg/grpc/auth" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" feature_v2beta "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" @@ -75,6 +76,7 @@ type Client struct { SCIM *scim.Client Projectv2Beta project_v2beta.ProjectServiceClient InstanceV2Beta instance.InstanceServiceClient + AppV2Beta app.AppServiceClient } func NewDefaultClient(ctx context.Context) (*Client, error) { @@ -114,6 +116,7 @@ func newClient(ctx context.Context, target string) (*Client, error) { SCIM: scim.NewScimClient(target), Projectv2Beta: project_v2beta.NewProjectServiceClient(cc), InstanceV2Beta: instance.NewInstanceServiceClient(cc), + AppV2Beta: app.NewAppServiceClient(cc), } return client, client.pollHealth(ctx) } diff --git a/internal/project/model/oidc_config.go b/internal/project/model/oidc_config.go index 50be6c318a..2c482a67a7 100644 --- a/internal/project/model/oidc_config.go +++ b/internal/project/model/oidc_config.go @@ -3,6 +3,8 @@ package model import ( "time" + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -98,7 +100,7 @@ func GetOIDCCompliance(version OIDCVersion, appType OIDCApplicationType, grantTy for i, grantType := range grantTypes { domainGrantTypes[i] = domain.OIDCGrantType(grantType) } - compliance := domain.GetOIDCV1Compliance(domain.OIDCApplicationType(appType), domainGrantTypes, domain.OIDCAuthMethodType(authMethod), redirectUris) + compliance := domain.GetOIDCV1Compliance(gu.Ptr(domain.OIDCApplicationType(appType)), domainGrantTypes, gu.Ptr(domain.OIDCAuthMethodType(authMethod)), redirectUris) return &Compliance{ NoneCompliant: compliance.NoneCompliant, Problems: compliance.Problems, diff --git a/internal/query/app.go b/internal/query/app.go index bc97c1807e..777c295139 100644 --- a/internal/query/app.go +++ b/internal/query/app.go @@ -5,9 +5,11 @@ import ( "database/sql" _ "embed" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" + "github.com/muhlemmer/gu" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" @@ -307,6 +309,19 @@ func (q *Queries) AppByProjectAndAppID(ctx context.Context, shouldTriggerBulk bo return app, err } +func (q *Queries) AppByIDWithPermission(ctx context.Context, appID string, activeOnly bool, permissionCheck domain.PermissionCheck) (*App, error) { + app, err := q.AppByID(ctx, appID, activeOnly) + if err != nil { + return nil, err + } + + if err := appCheckPermission(ctx, app.ResourceOwner, app.ProjectID, permissionCheck); err != nil { + return nil, err + } + + return app, nil +} + func (q *Queries) AppByID(ctx context.Context, appID string, activeOnly bool) (app *App, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -476,11 +491,54 @@ func (q *Queries) AppByOIDCClientID(ctx context.Context, clientID string) (app * return app, err } -func (q *Queries) SearchApps(ctx context.Context, queries *AppSearchQueries, withOwnerRemoved bool) (apps *Apps, err error) { +func (q *Queries) AppByClientID(ctx context.Context, clientID string) (app *App, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + stmt, scan := prepareAppQuery(true) + eq := sq.Eq{ + AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + AppColumnState.identifier(): domain.AppStateActive, + ProjectColumnState.identifier(): domain.ProjectStateActive, + OrgColumnState.identifier(): domain.OrgStateActive, + } + query, args, err := stmt.Where(sq.And{ + eq, + sq.Or{ + sq.Eq{AppOIDCConfigColumnClientID.identifier(): clientID}, + sq.Eq{AppAPIConfigColumnClientID.identifier(): clientID}, + }, + }).ToSql() + if err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-Dfge2", "Errors.Query.SQLStatement") + } + + err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { + app, err = scan(row) + return err + }, query, args...) + return app, err +} + +func (q *Queries) SearchApps(ctx context.Context, queries *AppSearchQueries, permissionCheck domain.PermissionCheck) (*Apps, error) { + apps, err := q.searchApps(ctx, queries, PermissionV2(ctx, permissionCheck)) + if err != nil { + return nil, err + } + + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + apps.Apps = appsCheckPermission(ctx, apps.Apps, permissionCheck) + } + return apps, nil +} + +func (q *Queries) searchApps(ctx context.Context, queries *AppSearchQueries, isPermissionV2Enabled bool) (apps *Apps, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := prepareAppsQuery() + query = appPermissionCheckV2(ctx, query, isPermissionV2Enabled, queries) + eq := sq.Eq{AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()} stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { @@ -498,6 +556,21 @@ func (q *Queries) SearchApps(ctx context.Context, queries *AppSearchQueries, wit return apps, err } +func appPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *AppSearchQueries) sq.SelectBuilder { + if !enabled { + return query + } + + join, args := PermissionClause( + ctx, + AppColumnResourceOwner, + domain.PermissionProjectAppRead, + SingleOrgPermissionOption(queries.Queries), + WithProjectsPermissionOption(AppColumnProjectID), + ) + return query.JoinClause(join, args...) +} + func (q *Queries) SearchClientIDs(ctx context.Context, queries *AppSearchQueries, shouldTriggerBulk bool) (ids []string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -574,10 +647,25 @@ func (q *Queries) SAMLAppLoginVersion(ctx context.Context, appID string) (loginV return loginVersion, nil } +func appCheckPermission(ctx context.Context, resourceOwner string, projectID string, permissionCheck domain.PermissionCheck) error { + return permissionCheck(ctx, domain.PermissionProjectAppRead, resourceOwner, projectID) +} + +// appsCheckPermission returns only the apps that the user in context has permission to read +func appsCheckPermission(ctx context.Context, apps []*App, permissionCheck domain.PermissionCheck) []*App { + return slices.DeleteFunc(apps, func(app *App) bool { + return permissionCheck(ctx, domain.PermissionProjectAppRead, app.ResourceOwner, app.ProjectID) != nil + }) +} + func NewAppNameSearchQuery(method TextComparison, value string) (SearchQuery, error) { return NewTextQuery(AppColumnName, value, method) } +func NewAppStateSearchQuery(value domain.AppState) (SearchQuery, error) { + return NewNumberQuery(AppColumnState, int(value), NumberEquals) +} + func NewAppProjectIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(AppColumnProjectID, id, TextEquals) } @@ -1089,7 +1177,7 @@ func (c sqlOIDCConfig) set(app *App) { if c.loginBaseURI.Valid { app.OIDCConfig.LoginBaseURI = &c.loginBaseURI.String } - compliance := domain.GetOIDCCompliance(app.OIDCConfig.Version, app.OIDCConfig.AppType, app.OIDCConfig.GrantTypes, app.OIDCConfig.ResponseTypes, app.OIDCConfig.AuthMethodType, app.OIDCConfig.RedirectURIs) + compliance := domain.GetOIDCCompliance(gu.Ptr(app.OIDCConfig.Version), gu.Ptr(app.OIDCConfig.AppType), app.OIDCConfig.GrantTypes, app.OIDCConfig.ResponseTypes, gu.Ptr(app.OIDCConfig.AuthMethodType), app.OIDCConfig.RedirectURIs) app.OIDCConfig.ComplianceProblems = compliance.Problems var err error diff --git a/pkg/grpc/app/v2beta/application.go b/pkg/grpc/app/v2beta/application.go new file mode 100644 index 0000000000..bbce4289f9 --- /dev/null +++ b/pkg/grpc/app/v2beta/application.go @@ -0,0 +1,5 @@ +package app + +type ApplicationConfig = isApplication_Config + +type MetaType = isUpdateSAMLApplicationConfigurationRequest_Metadata \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/api.proto b/proto/zitadel/app/v2beta/api.proto new file mode 100644 index 0000000000..9ef09d5ad8 --- /dev/null +++ b/proto/zitadel/app/v2beta/api.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "protoc-gen-openapiv2/options/annotations.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +enum APIAuthMethodType { + API_AUTH_METHOD_TYPE_BASIC = 0; + API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT = 1; +} + +message APIConfig { + string client_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334@ZITADEL\""; + description: "generated oauth2/oidc client_id"; + } + ]; + APIAuthMethodType auth_method_type = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines how the API passes the login credentials"; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/app.proto b/proto/zitadel/app/v2beta/app.proto new file mode 100644 index 0000000000..f85e3c021d --- /dev/null +++ b/proto/zitadel/app/v2beta/app.proto @@ -0,0 +1,94 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "zitadel/app/v2beta/oidc.proto"; +import "zitadel/app/v2beta/saml.proto"; +import "zitadel/app/v2beta/api.proto"; +import "zitadel/filter/v2/filter.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; +import "validate/validate.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message Application { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + } + ]; + + // The timestamp of the app creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + // The timestamp of the app update. + google.protobuf.Timestamp change_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + AppState state = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "current state of the application"; + } + ]; + string name = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Console\""; + } + ]; + oneof config { + OIDCConfig oidc_config = 6; + APIConfig api_config = 7; + SAMLConfig saml_config = 8; + } +} + +enum AppState { + APP_STATE_UNSPECIFIED = 0; + APP_STATE_ACTIVE = 1; + APP_STATE_INACTIVE = 2; + APP_STATE_REMOVED = 3; +} + +enum AppSorting { + APP_SORT_BY_ID = 0; + APP_SORT_BY_NAME = 1; + APP_SORT_BY_STATE = 2; + APP_SORT_BY_CREATION_DATE = 3; + APP_SORT_BY_CHANGE_DATE = 4; +} + +message ApplicationSearchFilter { + oneof filter { + option (validate.required) = true; + ApplicationNameQuery name_filter = 1; + AppState state_filter = 2; + bool api_app_only = 3; + bool oidc_app_only = 4; + bool saml_app_only = 5; + } +} + +message ApplicationNameQuery { + string name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Conso\"" + } + ]; + + zitadel.filter.v2.TextFilterMethod 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/app/v2beta/app_service.proto b/proto/zitadel/app/v2beta/app_service.proto new file mode 100644 index 0000000000..a881022caa --- /dev/null +++ b/proto/zitadel/app/v2beta/app_service.proto @@ -0,0 +1,788 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "zitadel/app/v2beta/login.proto"; +import "zitadel/app/v2beta/oidc.proto"; +import "zitadel/app/v2beta/api.proto"; +import "zitadel/app/v2beta/app.proto"; +import "google/protobuf/timestamp.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "zitadel/filter/v2/filter.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Application Service"; + version: "2.0-beta"; + description: "This API is intended to manage apps (SAML, OIDC, etc..) in a ZITADEL instance. This service is in beta state. It can AND will continue breaking until a stable version is released."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSING.md"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +// Service to manage apps. +// The service provides methods to create, update, delete and list apps and app keys. +service AppService { + + // Create Application + // + // Create an application. The application can be OIDC, API or SAML type, based on the input. + // + // The user needs to have project.app.write permission + // + // Required permissions: + // - project.app.write + rpc CreateApplication(CreateApplicationRequest) returns (CreateApplicationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The created application"; + } + }; + }; + + option (google.api.http) = { + post: "/v2beta/applications" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Update Application + // + // Changes the configuration of an OIDC, API or SAML type application, as well as + // the application name, based on the input provided. + // + // The user needs to have project.app.write permission + // + // Required permissions: + // - project.app.write + rpc UpdateApplication(UpdateApplicationRequest) returns (UpdateApplicationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The updated app."; + } + }; + }; + + option (google.api.http) = { + patch: "/v2beta/applications/{id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Get Application + // + // Retrieves the application matching the provided ID. + // + // The user needs to have project.app.read permission + // + // Required permissions: + // - project.app.read + rpc GetApplication(GetApplicationRequest) returns (GetApplicationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The fetched app."; + } + }; + }; + + option (google.api.http) = { + get: "/v2beta/applications/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Delete Application + // + // Deletes the application belonging to the input project and matching the provided + // application ID + // + // The user needs to have project.app.delete permission + // + // Required permissions: + // - project.app.delete + rpc DeleteApplication(DeleteApplicationRequest) returns (DeleteApplicationResponse) { + option (google.api.http) = { + delete: "/v2beta/applications/{id}" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The time of deletion."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Deactivate Application + // + // Deactivates the application belonging to the input project and matching the provided + // application ID + // + // The user needs to have project.app.write permission + // + // Required permissions: + // - project.app.write + rpc DeactivateApplication(DeactivateApplicationRequest) returns (DeactivateApplicationResponse) { + option (google.api.http) = { + post: "/v2beta/applications/{id}/deactivate" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The time of deactivation."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Reactivate Application + // + // Reactivates the application belonging to the input project and matching the provided + // application ID + // + // The user needs to have project.app.write permission + // + // Required permissions: + // - project.app.write + rpc ReactivateApplication(ReactivateApplicationRequest) returns (ReactivateApplicationResponse) { + option (google.api.http) = { + post: "/v2beta/applications/{id}/reactivate" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The time of reactivation."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + + // Regenerate Client Secret + // + // Regenerates the client secret of an API or OIDC application that belongs to the input project. + // + // The user needs to have project.app.write permission + // + // Required permissions: + // - project.app.write + rpc RegenerateClientSecret(RegenerateClientSecretRequest) returns (RegenerateClientSecretResponse) { + option (google.api.http) = { + post: "/v2beta/applications/{application_id}/generate_client_secret" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The regenerated client secret."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // List Applications + // + // Returns a list of applications matching the input parameters that belong to the provided + // project. + // + // The result can be sorted by app id, name, creation date, change date or state. It can also + // be filtered by app state, app type and app name. + // + // The user needs to have project.app.read permission + // + // Required permissions: + // - project.app.read + rpc ListApplications(ListApplicationsRequest) returns (ListApplicationsResponse) { + option (google.api.http) = { + post: "/v2beta/applications/search" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The matching applications"; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } +} + +message CreateApplicationRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {max_len: 200}]; + string name = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"MyApp\""; + } + ]; + oneof creation_request_type { + option (validate.required) = true; + CreateOIDCApplicationRequest oidc_request = 4; + CreateSAMLApplicationRequest saml_request = 5; + CreateAPIApplicationRequest api_request = 6; + } +} + +message CreateApplicationResponse { + string app_id = 1; + // The timestamp of the app creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + oneof creation_response_type { + CreateOIDCApplicationResponse oidc_response = 3; + CreateSAMLApplicationResponse saml_response = 4; + CreateAPIApplicationResponse api_response = 5; + } +} + +message CreateOIDCApplicationRequest { + // Callback URI of the authorization request where the code or tokens will be sent to + repeated string redirect_uris = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/auth/callback\"]"; + description: "Callback URI of the authorization request where the code or tokens will be sent to"; + } + ]; + repeated OIDCResponseType response_types = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines whether a code, id_token token or just id_token will be returned" + } + ]; + repeated OIDCGrantType grant_types = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The flow type the application uses to gain access"; + } + ]; + OIDCAppType app_type = 4 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines the paradigm of the application"; + } + ]; + OIDCAuthMethodType auth_method_type = 5 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Defines how the application passes login credentials"; + } + ]; + + // ZITADEL will redirect to this link after a successful logout + repeated string post_logout_redirect_uris = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/signedout\"]"; + description: "ZITADEL will redirect to this link after a successful logout"; + } + ]; + OIDCVersion version = 7 [(validate.rules).enum = {defined_only: true}]; + bool dev_mode = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used for development, some checks of the OIDC specification will not be checked."; + } + ]; + OIDCTokenType access_token_type = 9 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Type of the access token returned from ZITADEL"; + } + ]; + bool access_token_role_assertion = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the access token (only if type == JWT) even if they are not requested by scopes"; + } + ]; + bool id_token_role_assertion = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the id token even if they are not requested by scopes"; + } + ]; + bool id_token_userinfo_assertion = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Claims of profile, email, address and phone scopes are added to the id token even if an access token is issued. Attention this violates the OIDC specification"; + } + ]; + google.protobuf.Duration clock_skew = 13 [ + (validate.rules).duration = {gte: {}, lte: {seconds: 5}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used to compensate time difference of servers. Duration added to the \"exp\" claim and subtracted from \"iat\", \"auth_time\" and \"nbf\" claims"; + example: "\"1s\""; + } + ]; + repeated string additional_origins = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"scheme://localhost:8080\"]"; + description: "Additional origins (other than the redirect_uris) from where the API can be used, provided string has to be an origin (scheme://hostname[:port]) without path, query or fragment"; + } + ]; + bool skip_native_app_success_page = 15 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Skip the successful login page on native apps and directly redirect the user to the callback."; + } + ]; + string back_channel_logout_uri = 16 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://example.com/auth/backchannel\"]"; + description: "ZITADEL will use this URI to notify the application about terminated session according to the OIDC Back-Channel Logout (https://openid.net/specs/openid-connect-backchannel-1_0.html)"; + } + ]; + LoginVersion login_version = 17 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message CreateOIDCApplicationResponse { + string client_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"1035496534033449\""; + description: "generated client id for this config"; + } + ]; + string client_secret = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"gjoq34589uasgh\""; + description: "generated secret for this config"; + } + ]; + bool none_compliant = 3; + repeated OIDCLocalizedMessage compliance_problems = 4; +} + +message CreateSAMLApplicationRequest { + oneof metadata { + option (validate.required) = true; + bytes metadata_xml = 1 [(validate.rules).bytes.max_len = 500000]; + string metadata_url = 2 [(validate.rules).string.max_len = 200]; + } + LoginVersion login_version = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message CreateSAMLApplicationResponse {} + +message CreateAPIApplicationRequest { + APIAuthMethodType auth_method_type = 1 [(validate.rules).enum = {defined_only: true}]; +} + +message CreateAPIApplicationResponse { + string client_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"3950723409029374\""; + description: "generated secret for this config"; + } + ]; + string client_secret = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"gjoq34589uasgh\""; + description: "generated secret for this config"; + } + ]; +} + +message UpdateApplicationRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"45984352431\""; + } + ]; + string name = 3 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"MyApplicationName\""; + min_length: 1; + max_length: 200; + } + ]; + + oneof update_request_type { + UpdateSAMLApplicationConfigurationRequest saml_configuration_request = 4; + UpdateOIDCApplicationConfigurationRequest oidc_configuration_request = 5; + UpdateAPIApplicationConfigurationRequest api_configuration_request = 6; + } +} + +message UpdateApplicationResponse { + // The timestamp of the app update. + google.protobuf.Timestamp change_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message UpdateSAMLApplicationConfigurationRequest { + oneof metadata { + option (validate.required) = true; + bytes metadata_xml = 1 [(validate.rules).bytes.max_len = 500000]; + string metadata_url = 2 [(validate.rules).string.max_len = 200]; + } + optional LoginVersion login_version = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message UpdateOIDCApplicationConfigurationRequest { + repeated string redirect_uris = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/auth/callback\"]"; + description: "Callback URI of the authorization request where the code or tokens will be sent to"; + } + ]; + repeated OIDCResponseType response_types = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines whether a code, id_token token or just id_token will be returned" + } + ]; + repeated OIDCGrantType grant_types = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The flow type the application uses to gain access"; + } + ]; + optional OIDCAppType app_type = 4 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines the paradigm of the application"; + } + ]; + optional OIDCAuthMethodType auth_method_type = 5 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Defines how the application passes login credentials"; + } + ]; + repeated string post_logout_redirect_uris = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/signedout\"]"; + description: "ZITADEL will redirect to this link after a successful logout"; + } + ]; + optional OIDCVersion version = 7 [(validate.rules).enum = {defined_only: true}]; + optional bool dev_mode = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used for development, some checks of the OIDC specification will not be checked."; + } + ]; + optional OIDCTokenType access_token_type = 9 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Type of the access token returned from ZITADEL"; + } + ]; + optional bool access_token_role_assertion = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the access token (only if type == JWT) even if they are not requested by scopes"; + } + ]; + optional bool id_token_role_assertion = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the id token even if they are not requested by scopes"; + } + ]; + optional bool id_token_userinfo_assertion = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Claims of profile, email, address and phone scopes are added to the id token even if an access token is issued. Attention this violates the OIDC specification"; + } + ]; + optional google.protobuf.Duration clock_skew = 13 [ + (validate.rules).duration = {gte: {}, lte: {seconds: 5}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used to compensate time difference of servers. Duration added to the \"exp\" claim and subtracted from \"iat\", \"auth_time\" and \"nbf\" claims"; + example: "\"1s\""; + } + ]; + repeated string additional_origins = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"scheme://localhost:8080\"]"; + description: "Additional origins (other than the redirect_uris) from where the API can be used, provided string has to be an origin (scheme://hostname[:port]) without path, query or fragment"; + } + ]; + optional bool skip_native_app_success_page = 15 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Skip the successful login page on native apps and directly redirect the user to the callback."; + } + ]; + optional string back_channel_logout_uri = 16 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://example.com/auth/backchannel\"]"; + description: "ZITADEL will use this URI to notify the application about terminated session according to the OIDC Back-Channel Logout (https://openid.net/specs/openid-connect-backchannel-1_0.html)"; + } + ]; + optional LoginVersion login_version = 17 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message UpdateAPIApplicationConfigurationRequest { + APIAuthMethodType auth_method_type = 1 [(validate.rules).enum = {defined_only: true}]; +} + +message GetApplicationRequest { + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"45984352431\""; + } + ]; +} + +message GetApplicationResponse { + Application app = 1; +} + +message DeleteApplicationRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message DeleteApplicationResponse { + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message DeactivateApplicationRequest{ + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message DeactivateApplicationResponse{ + google.protobuf.Timestamp deactivation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ReactivateApplicationRequest{ + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message ReactivateApplicationResponse{ + google.protobuf.Timestamp reactivation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message RegenerateClientSecretRequest{ + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string application_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; + oneof app_type { + option (validate.required) = true; + bool is_oidc = 3; + bool is_api = 4; + } +} + +message RegenerateClientSecretResponse{ + string client_secret = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"gjoq34589uasgh\""; + description: "generated secret for the client"; + } + ]; + + // The timestamp of the creation of the new client secret + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListApplicationsRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + + // Pagination and sorting. + zitadel.filter.v2.PaginationRequest pagination = 2; + + //criteria the client is looking for + repeated ApplicationSearchFilter filters = 3; + + AppSorting sorting_column = 4; +} + +message ListApplicationsResponse { + repeated Application applications = 1; + + // Contains the total number of apps matching the query and the applied limit. + zitadel.filter.v2.PaginationResponse pagination = 2; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/login.proto b/proto/zitadel/app/v2beta/login.proto new file mode 100644 index 0000000000..567b4b5167 --- /dev/null +++ b/proto/zitadel/app/v2beta/login.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message LoginVersion { + oneof version { + LoginV1 login_v1 = 1; + LoginV2 login_v2 = 2; + } +} + +message LoginV1 {} + +message LoginV2 { + // Optionally specify a base uri of the login UI. If unspecified the default URI will be used. + optional string base_uri = 1; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/oidc.proto b/proto/zitadel/app/v2beta/oidc.proto new file mode 100644 index 0000000000..7cfd1dcc43 --- /dev/null +++ b/proto/zitadel/app/v2beta/oidc.proto @@ -0,0 +1,166 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; +import "zitadel/app/v2beta/login.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/duration.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message OIDCLocalizedMessage { + string key = 1; + string localized_message = 2; +} + +enum OIDCResponseType { + OIDC_RESPONSE_TYPE_UNSPECIFIED = 0; + OIDC_RESPONSE_TYPE_CODE = 1; + OIDC_RESPONSE_TYPE_ID_TOKEN = 2; + OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN = 3; +} + +enum OIDCGrantType{ + OIDC_GRANT_TYPE_AUTHORIZATION_CODE = 0; + OIDC_GRANT_TYPE_IMPLICIT = 1; + OIDC_GRANT_TYPE_REFRESH_TOKEN = 2; + OIDC_GRANT_TYPE_DEVICE_CODE = 3; + OIDC_GRANT_TYPE_TOKEN_EXCHANGE = 4; +} + +enum OIDCAppType { + OIDC_APP_TYPE_WEB = 0; + OIDC_APP_TYPE_USER_AGENT = 1; + OIDC_APP_TYPE_NATIVE = 2; +} + +enum OIDCAuthMethodType { + OIDC_AUTH_METHOD_TYPE_BASIC = 0; + OIDC_AUTH_METHOD_TYPE_POST = 1; + OIDC_AUTH_METHOD_TYPE_NONE = 2; + OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT = 3; +} + +enum OIDCVersion { + OIDC_VERSION_1_0 = 0; +} + +enum OIDCTokenType { + OIDC_TOKEN_TYPE_BEARER = 0; + OIDC_TOKEN_TYPE_JWT = 1; +} + +message OIDCConfig { + repeated string redirect_uris = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/auth/callback\"]"; + description: "Callback URI of the authorization request where the code or tokens will be sent to"; + } + ]; + repeated OIDCResponseType response_types = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines whether a code, id_token token or just id_token will be returned" + } + ]; + repeated OIDCGrantType grant_types = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The flow type the application uses to gain access"; + } + ]; + OIDCAppType app_type = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "determines the paradigm of the application"; + } + ]; + string client_id = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334@ZITADEL\""; + description: "generated oauth2/oidc client id"; + } + ]; + OIDCAuthMethodType auth_method_type = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines how the application passes login credentials"; + } + ]; + repeated string post_logout_redirect_uris = 7 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/logout\"]"; + description: "ZITADEL will redirect to this link after a successful logout"; + } + ]; + OIDCVersion version = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the OIDC version used by the application"; + } + ]; + bool none_compliant = 9 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "specifies whether the config is OIDC compliant. A production configuration SHOULD be compliant"; + } + ]; + repeated OIDCLocalizedMessage compliance_problems = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "lists the problems for non-compliancy"; + } + ]; + bool dev_mode = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "used for development"; + } + ]; + OIDCTokenType access_token_type = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "type of the access token returned from ZITADEL"; + } + ]; + bool access_token_role_assertion = 13 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "adds roles to the claims of the access token (only if type == JWT) even if they are not requested by scopes"; + } + ]; + bool id_token_role_assertion = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "adds roles to the claims of the id token even if they are not requested by scopes"; + } + ]; + bool id_token_userinfo_assertion = 15 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "claims of profile, email, address and phone scopes are added to the id token even if an access token is issued. Attention this violates the OIDC specification"; + } + ]; + google.protobuf.Duration clock_skew = 16 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used to compensate time difference of servers. Duration added to the \"exp\" claim and subtracted from \"iat\", \"auth_time\" and \"nbf\" claims"; + // min: "0s"; + // max: "5s"; + } + ]; + repeated string additional_origins = 17 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/auth/callback\"]"; + description: "additional origins (other than the redirect_uris) from where the API can be used"; + } + ]; + repeated string allowed_origins = 18 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/auth/callback\"]"; + description: "all allowed origins from where the API can be used"; + } + ]; + bool skip_native_app_success_page = 19 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Skip the successful login page on native apps and directly redirect the user to the callback."; + } + ]; + string back_channel_logout_uri = 20 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://example.com/auth/backchannel\"]"; + description: "ZITADEL will use this URI to notify the application about terminated session according to the OIDC Back-Channel Logout (https://openid.net/specs/openid-connect-backchannel-1_0.html)"; + } + ]; + LoginVersion login_version = 21 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/saml.proto b/proto/zitadel/app/v2beta/saml.proto new file mode 100644 index 0000000000..7c85447880 --- /dev/null +++ b/proto/zitadel/app/v2beta/saml.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "zitadel/app/v2beta/login.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message SAMLConfig { + oneof metadata{ + bytes metadata_xml = 1; + string metadata_url = 2; + } + LoginVersion login_version = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index d633fbe8c5..74d5dcf60b 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -3287,6 +3287,7 @@ service ManagementService { }; } + // Deprecated: Use [GetApplication](/apis/resources/application_service_v2/application-service-get-application.api.mdx) instead to fetch an app rpc GetAppByID(GetAppByIDRequest) returns (GetAppByIDResponse) { option (google.api.http) = { get: "/projects/{project_id}/apps/{app_id}" @@ -3309,9 +3310,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [ListApplications](/apis/resources/application_service_v2/application-service-list-applications.api.mdx) instead to list applications rpc ListApps(ListAppsRequest) returns (ListAppsResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/_search" @@ -3335,6 +3338,7 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } @@ -3363,6 +3367,7 @@ service ManagementService { }; } + // Deprecated: Use [CreateApplication](/apis/resources/application_service_v2/application-service-create-application.api.mdx) instead to create an OIDC application rpc AddOIDCApp(AddOIDCAppRequest) returns (AddOIDCAppResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/oidc" @@ -3386,62 +3391,74 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } - rpc AddSAMLApp(AddSAMLAppRequest) returns (AddSAMLAppResponse) { - option (google.api.http) = { - post: "/projects/{project_id}/apps/saml" - body: "*" - }; - - option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Create Application (SAML)"; - description: "Create a new SAML client. Returns an entity ID" - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; - } - - rpc AddAPIApp(AddAPIAppRequest) returns (AddAPIAppResponse) { - option (google.api.http) = { - post: "/projects/{project_id}/apps/api" - body: "*" - }; - - option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" + // Deprecated: Use [CreateApplication](/apis/resources/application_service_v2/application-service-create-application.api.mdx) instead to create a SAML application + rpc AddSAMLApp(AddSAMLAppRequest) returns (AddSAMLAppResponse) { + option (google.api.http) = { + post: "/projects/{project_id}/apps/saml" + body: "*" }; - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Create Application (API)"; - description: "Create a new API client. The client id will be generated and returned in the response. Depending on the chosen configuration also a secret will be generated and returned." - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; + option (zitadel.v1.auth_option) = { + permission: "project.app.write" + check_field_name: "ProjectId" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Create Application (SAML)"; + description: "Create a new SAML client. Returns an entity ID" + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; + } + + // Create Application (API) + // + // Create a new API client. The client id will be generated and returned in the response. + // Depending on the chosen configuration also a secret will be generated and returned. + // + // Deprecated: Use [CreateApplication](/apis/resources/application_service_v2/application-service-create-application.api.mdx) instead to create an API application + rpc AddAPIApp(AddAPIAppRequest) returns (AddAPIAppResponse) { + option (google.api.http) = { + post: "/projects/{project_id}/apps/api" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "project.app.write" + check_field_name: "ProjectId" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Create Application (API)"; + description: "Create a new API client. The client id will be generated and returned in the response. Depending on the chosen configuration also a secret will be generated and returned." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; } // Changes application + // + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the generic params of an app rpc UpdateApp(UpdateAppRequest) returns (UpdateAppResponse) { option (google.api.http) = { put: "/projects/{project_id}/apps/{app_id}" @@ -3465,9 +3482,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the config of an OIDC app rpc UpdateOIDCAppConfig(UpdateOIDCAppConfigRequest) returns (UpdateOIDCAppConfigResponse) { option (google.api.http) = { put: "/projects/{project_id}/apps/{app_id}/oidc_config" @@ -3491,61 +3510,67 @@ service ManagementService { required: false; }; }; + deprecated: true }; } - rpc UpdateSAMLAppConfig(UpdateSAMLAppConfigRequest) returns (UpdateSAMLAppConfigResponse) { - option (google.api.http) = { - put: "/projects/{project_id}/apps/{app_id}/saml_config" - body: "*" - }; - - option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Update SAML Application Config"; - description: "Update the SAML specific configuration of an application." - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; - } - - rpc UpdateAPIAppConfig(UpdateAPIAppConfigRequest) returns (UpdateAPIAppConfigResponse) { - option (google.api.http) = { - put: "/projects/{project_id}/apps/{app_id}/api_config" - body: "*" - }; + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the config of a SAML app + rpc UpdateSAMLAppConfig(UpdateSAMLAppConfigRequest) returns (UpdateSAMLAppConfigResponse) { + option (google.api.http) = { + put: "/projects/{project_id}/apps/{app_id}/saml_config" + body: "*" + }; option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" + permission: "project.app.write" + check_field_name: "ProjectId" }; - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Update API Application Config"; - description: "Update the OIDC-specific configuration of an application." - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Update SAML Application Config"; + description: "Update the SAML specific configuration of an application." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; } + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the config of an API app + rpc UpdateAPIAppConfig(UpdateAPIAppConfigRequest) returns (UpdateAPIAppConfigResponse) { + option (google.api.http) = { + put: "/projects/{project_id}/apps/{app_id}/api_config" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "project.app.write" + check_field_name: "ProjectId" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Update API Application Config"; + description: "Update the OIDC-specific configuration of an application." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; + } + + // Deprecated: Use [DeactivateApplication](/apis/resources/application_service_v2/application-service-deactivate-application.api.mdx) instead to deactivate an app rpc DeactivateApp(DeactivateAppRequest) returns (DeactivateAppResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/_deactivate" @@ -3569,9 +3594,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [ReactivateApplication](/apis/resources/application_service_v2/application-service-reactivate-application.api.mdx) instead to reactivate an app rpc ReactivateApp(ReactivateAppRequest) returns (ReactivateAppResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/_reactivate" @@ -3595,9 +3622,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [DeleteApplication](/apis/resources/application_service_v2/application-service-delete-application.api.mdx) instead to delete an app rpc RemoveApp(RemoveAppRequest) returns (RemoveAppResponse) { option (google.api.http) = { delete: "/projects/{project_id}/apps/{app_id}" @@ -3620,9 +3649,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [RegenerateClientSecret](/apis/resources/application_service_v2/application-service-regenerate-client-secret.api.mdx) instead to regenerate an OIDC app client secret rpc RegenerateOIDCClientSecret(RegenerateOIDCClientSecretRequest) returns (RegenerateOIDCClientSecretResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/oidc_config/_generate_client_secret" @@ -3646,9 +3677,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [RegenerateClientSecret](/apis/resources/application_service_v2/application-service-regenerate-client-secret.api.mdx) instead to regenerate an API app client secret rpc RegenerateAPIClientSecret(RegenerateAPIClientSecretRequest) returns (RegenerateAPIClientSecretResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/api_config/_generate_client_secret" @@ -3672,6 +3705,7 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } From 14b45b58ebc955f7ee2b1161b74bd2f57ed07916 Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Fri, 27 Jun 2025 13:46:21 -0700 Subject: [PATCH 11/66] chore: add inkeep search and ai to docs (#10119) --- docs/docusaurus.config.js | 111 ++- docs/package.json | 1 + docs/yarn.lock | 1776 ++++++++++++++++++++++++++++++------- 3 files changed, 1508 insertions(+), 380 deletions(-) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 43830eafd0..abf5c742a5 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -71,13 +71,13 @@ module.exports = { label: "🚀 Quick Start", docId: "guides/start/quickstart", position: "left", - }, + }, { type: "doc", label: "Documentation", docId: "guides/overview", position: "left", - }, + }, { type: "doc", label: "APIs", @@ -174,20 +174,25 @@ module.exports = { { label: "Status", href: "https://status.zitadel.com/", - } + }, ], }, ], copyright: `Copyright © ${new Date().getFullYear()} ZITADEL Docs - Built with Docusaurus.`, }, - algolia: { - appId: "8H6ZKXENLO", - apiKey: "124fe1c102a184bc6fc70c75dc84f96f", - indexName: "zitadel", - selector: "div#", - }, prism: { - additionalLanguages: ["csharp", "dart", "groovy", "regex", "java", "php", "python", "protobuf", "json", "bash"], + additionalLanguages: [ + "csharp", + "dart", + "groovy", + "regex", + "java", + "php", + "python", + "protobuf", + "json", + "bash", + ], }, colorMode: { defaultMode: "dark", @@ -196,9 +201,9 @@ module.exports = { }, codeblock: { showGithubLink: true, - githubLinkLabel: 'View on GitHub', + githubLinkLabel: "View on GitHub", showRunmeLink: false, - runmeLinkLabel: 'Checkout via Runme' + runmeLinkLabel: "Checkout via Runme", }, }, presets: [ @@ -213,19 +218,33 @@ module.exports = { showLastUpdateTime: true, editUrl: "https://github.com/zitadel/zitadel/edit/main/docs/", remarkPlugins: [require("mdx-mermaid")], - - docItemComponent: '@theme/ApiItem' + + docItemComponent: "@theme/ApiItem", }, theme: { customCss: require.resolve("./src/css/custom.css"), }, - }) + }), ], - ], plugins: [ [ - 'docusaurus-plugin-openapi-docs', + "@inkeep/cxkit-docusaurus", + { + SearchBar: { + baseSettings: { + apiKey: process.env.INKEEP_API_KEY, + primaryBrandColor: "#ff2069", + organizationDisplayName: "ZITADEL", + }, + }, + SearchSettings: { + tabs: ["All", "Docs", "GitHub", "Forums", "Discord"], + }, + }, + ], + [ + "docusaurus-plugin-openapi-docs", { id: "apiDocs", docsPluginId: "classic", @@ -263,7 +282,8 @@ module.exports = { }, }, user_v2: { - specPath: ".artifacts/openapi/zitadel/user/v2/user_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/user/v2/user_service.swagger.json", outputDir: "docs/apis/resources/user_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -271,7 +291,8 @@ module.exports = { }, }, session_v2: { - specPath: ".artifacts/openapi/zitadel/session/v2/session_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/session/v2/session_service.swagger.json", outputDir: "docs/apis/resources/session_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -279,7 +300,8 @@ module.exports = { }, }, oidc_v2: { - specPath: ".artifacts/openapi/zitadel/oidc/v2/oidc_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/oidc/v2/oidc_service.swagger.json", outputDir: "docs/apis/resources/oidc_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -287,7 +309,8 @@ module.exports = { }, }, saml_v2: { - specPath: ".artifacts/openapi/zitadel/saml/v2/saml_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/saml/v2/saml_service.swagger.json", outputDir: "docs/apis/resources/saml_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -295,7 +318,8 @@ module.exports = { }, }, settings_v2: { - specPath: ".artifacts/openapi/zitadel/settings/v2/settings_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/settings/v2/settings_service.swagger.json", outputDir: "docs/apis/resources/settings_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -303,31 +327,35 @@ module.exports = { }, }, action_v2: { - specPath: ".artifacts/openapi/zitadel/action/v2beta/action_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/action/v2beta/action_service.swagger.json", outputDir: "docs/apis/resources/action_service_v2", sidebarOptions: { - groupPathsBy: "tag", - categoryLinkSource: "auto", + groupPathsBy: "tag", + categoryLinkSource: "auto", }, }, webkey_v2: { - specPath: ".artifacts/openapi/zitadel/webkey/v2beta/webkey_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/webkey/v2beta/webkey_service.swagger.json", outputDir: "docs/apis/resources/webkey_service_v2", sidebarOptions: { - groupPathsBy: "tag", - categoryLinkSource: "auto", + groupPathsBy: "tag", + categoryLinkSource: "auto", }, }, feature_v2: { - specPath: ".artifacts/openapi/zitadel/feature/v2/feature_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/feature/v2/feature_service.swagger.json", outputDir: "docs/apis/resources/feature_service_v2", sidebarOptions: { - groupPathsBy: "tag", - categoryLinkSource: "auto", + groupPathsBy: "tag", + categoryLinkSource: "auto", }, }, org_v2: { - specPath: ".artifacts/openapi/zitadel/org/v2/org_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/org/v2/org_service.swagger.json", outputDir: "docs/apis/resources/org_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -335,7 +363,8 @@ module.exports = { }, }, idp_v2: { - specPath: ".artifacts/openapi/zitadel/idp/v2/idp_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/idp/v2/idp_service.swagger.json", outputDir: "docs/apis/resources/idp_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -343,7 +372,8 @@ module.exports = { }, }, org_v2beta: { - specPath: ".artifacts/openapi/zitadel/org/v2beta/org_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/org/v2beta/org_service.swagger.json", outputDir: "docs/apis/resources/org_service_v2beta", sidebarOptions: { groupPathsBy: "tag", @@ -351,7 +381,8 @@ module.exports = { }, }, project_v2beta: { - specPath: ".artifacts/openapi/zitadel/project/v2beta/project_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/project/v2beta/project_service.swagger.json", outputDir: "docs/apis/resources/project_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -359,7 +390,8 @@ module.exports = { }, }, instance_v2: { - specPath: ".artifacts/openapi/zitadel/instance/v2beta/instance_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/instance/v2beta/instance_service.swagger.json", outputDir: "docs/apis/resources/instance_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -382,13 +414,16 @@ module.exports = { }; }, ], - themes: [ "docusaurus-theme-github-codeblock", "docusaurus-theme-openapi-docs"], + themes: [ + "docusaurus-theme-github-codeblock", + "docusaurus-theme-openapi-docs", + ], future: { v4: false, // Disabled because of some problems related to https://github.com/facebook/docusaurus/issues/11040 experimental_faster: { swcJsLoader: false, // Disabled because of memory usage > 8GB which is a problem on vercel default runners swcJsMinimizer: true, - swcHtmlMinimizer : true, + swcHtmlMinimizer: true, lightningCssMinimizer: true, mdxCrossCompilerCache: true, ssgWorkerThreads: false, // Disabled because of some problems related to https://github.com/facebook/docusaurus/issues/11040 diff --git a/docs/package.json b/docs/package.json index 2c9eb8bb84..2e1214f378 100644 --- a/docs/package.json +++ b/docs/package.json @@ -29,6 +29,7 @@ "@docusaurus/theme-search-algolia": "^3.8.1", "@headlessui/react": "^1.7.4", "@heroicons/react": "^2.0.13", + "@inkeep/cxkit-docusaurus": "^0.5.89", "autoprefixer": "^10.4.13", "clsx": "^1.2.1", "docusaurus-plugin-image-zoom": "^3.0.1", diff --git a/docs/yarn.lock b/docs/yarn.lock index c933386f97..c48c5b8bd6 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -29,126 +29,126 @@ resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.9.tgz#5f38868f7cb1d54b014b17a10fc4f7e79d427fa8" integrity sha512-iDf05JDQ7I0b7JEA/9IektxN/80a2MZ1ToohfmNS3rfeuQnIKI3IJlIafD0xu4StbtQTghx9T3Maa97ytkXenQ== -"@algolia/client-abtesting@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-abtesting/-/client-abtesting-5.25.0.tgz#012204f1614e1a71366fb1e117c8f195186ff081" - integrity sha512-1pfQulNUYNf1Tk/svbfjfkLBS36zsuph6m+B6gDkPEivFmso/XnRgwDvjAx80WNtiHnmeNjIXdF7Gos8+OLHqQ== +"@algolia/client-abtesting@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-abtesting/-/client-abtesting-5.29.0.tgz#af9928f3b206cc5224e30256ea27d4e4d6023f22" + integrity sha512-AM/6LYMSTnZvAT5IarLEKjYWOdV+Fb+LVs8JRq88jn8HH6bpVUtjWdOZXqX1hJRXuCAY8SdQfb7F8uEiMNXdYQ== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/client-analytics@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-5.25.0.tgz#eba015bfafb3dbb82712c9160a00717a5974ff71" - integrity sha512-AFbG6VDJX/o2vDd9hqncj1B6B4Tulk61mY0pzTtzKClyTDlNP0xaUiEKhl6E7KO9I/x0FJF5tDCm0Hn6v5x18A== +"@algolia/client-analytics@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-5.29.0.tgz#d71b2f6e6c77c390343ee0ab73806378adb295eb" + integrity sha512-La34HJh90l0waw3wl5zETO8TuukeUyjcXhmjYZL3CAPLggmKv74mobiGRIb+mmBENybiFDXf/BeKFLhuDYWMMQ== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/client-common@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.25.0.tgz#2def8947efe849266057d92f67d1b8d83de0c005" - integrity sha512-il1zS/+Rc6la6RaCdSZ2YbJnkQC6W1wiBO8+SH+DE6CPMWBU6iDVzH0sCKSAtMWl9WBxoN6MhNjGBnCv9Yy2bA== +"@algolia/client-common@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.29.0.tgz#0908e90c5dc881be08eab4e595bf981e23525474" + integrity sha512-T0lzJH/JiCxQYtCcnWy7Jf1w/qjGDXTi2npyF9B9UsTvXB97GRC6icyfXxe21mhYvhQcaB1EQ/J2575FXxi2rA== -"@algolia/client-insights@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-insights/-/client-insights-5.25.0.tgz#b87df8614b96c4cc9c9aa7765cce07fa70864fa8" - integrity sha512-blbjrUH1siZNfyCGeq0iLQu00w3a4fBXm0WRIM0V8alcAPo7rWjLbMJMrfBtzL9X5ic6wgxVpDADXduGtdrnkw== +"@algolia/client-insights@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-insights/-/client-insights-5.29.0.tgz#80ca3c3d16ff2fa78b3a6a091a10ae508977dffa" + integrity sha512-A39F1zmHY9aev0z4Rt3fTLcGN5AG1VsVUkVWy6yQG5BRDScktH+U5m3zXwThwniBTDV1HrPgiGHZeWb67GkR2Q== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/client-personalization@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-5.25.0.tgz#74b041f0e7d91e1009c131c8d716c34e4d45c30f" - integrity sha512-aywoEuu1NxChBcHZ1pWaat0Plw7A8jDMwjgRJ00Mcl7wGlwuPt5dJ/LTNcg3McsEUbs2MBNmw0ignXBw9Tbgow== +"@algolia/client-personalization@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-5.29.0.tgz#1bc8882fe889ad25132794b7beecf1cfc0783acc" + integrity sha512-ibxmh2wKKrzu5du02gp8CLpRMeo+b/75e4ORct98CT7mIxuYFXowULwCd6cMMkz/R0LpKXIbTUl15UL5soaiUQ== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/client-query-suggestions@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-query-suggestions/-/client-query-suggestions-5.25.0.tgz#e92d935d9e2994f790d43c64d3518d81070a3888" - integrity sha512-a/W2z6XWKjKjIW1QQQV8PTTj1TXtaKx79uR3NGBdBdGvVdt24KzGAaN7sCr5oP8DW4D3cJt44wp2OY/fZcPAVA== +"@algolia/client-query-suggestions@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-query-suggestions/-/client-query-suggestions-5.29.0.tgz#784001417cee2ffde376f10074a477eef1eb095d" + integrity sha512-VZq4/AukOoJC2WSwF6J5sBtt+kImOoBwQc1nH3tgI+cxJBg7B77UsNC+jT6eP2dQCwGKBBRTmtPLUTDDnHpMgA== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/client-search@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.25.0.tgz#dc38ca1015f2f4c9f5053a4517f96fb28a2117f8" - integrity sha512-9rUYcMIBOrCtYiLX49djyzxqdK9Dya/6Z/8sebPn94BekT+KLOpaZCuc6s0Fpfq7nx5J6YY5LIVFQrtioK9u0g== +"@algolia/client-search@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.29.0.tgz#91c9a036b6677d954cd87d9262850f73f145bf81" + integrity sha512-cZ0Iq3OzFUPpgszzDr1G1aJV5UMIZ4VygJ2Az252q4Rdf5cQMhYEIKArWY/oUjMhQmosM8ygOovNq7gvA9CdCg== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" "@algolia/events@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@algolia/events/-/events-4.0.1.tgz#fd39e7477e7bc703d7f893b556f676c032af3950" integrity sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ== -"@algolia/ingestion@1.25.0": - version "1.25.0" - resolved "https://registry.yarnpkg.com/@algolia/ingestion/-/ingestion-1.25.0.tgz#4d13c56dda0a05c7bacb0e3ef5866292dfd86ed5" - integrity sha512-jJeH/Hk+k17Vkokf02lkfYE4A+EJX+UgnMhTLR/Mb+d1ya5WhE+po8p5a/Nxb6lo9OLCRl6w3Hmk1TX1e9gVbQ== +"@algolia/ingestion@1.29.0": + version "1.29.0" + resolved "https://registry.yarnpkg.com/@algolia/ingestion/-/ingestion-1.29.0.tgz#9d7f30a7161b1cb612309f8240aa471faac8a21f" + integrity sha512-scBXn0wO5tZCxmO6evfa7A3bGryfyOI3aoXqSQBj5SRvNYXaUlFWQ/iKI70gRe/82ICwE0ICXbHT/wIvxOW7vw== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/monitoring@1.25.0": - version "1.25.0" - resolved "https://registry.yarnpkg.com/@algolia/monitoring/-/monitoring-1.25.0.tgz#d59360cfe556338519d05a9d8107147e9dbcb020" - integrity sha512-Ls3i1AehJ0C6xaHe7kK9vPmzImOn5zBg7Kzj8tRYIcmCWVyuuFwCIsbuIIz/qzUf1FPSWmw0TZrGeTumk2fqXg== +"@algolia/monitoring@1.29.0": + version "1.29.0" + resolved "https://registry.yarnpkg.com/@algolia/monitoring/-/monitoring-1.29.0.tgz#919f86b7c53f1ea7c78f4c0ed9bd7917c1ca3a67" + integrity sha512-FGWWG9jLFhsKB7YiDjM2dwQOYnWu//7Oxrb2vT96N7+s+hg1mdHHfHNRyEudWdxd4jkMhBjeqNA21VbTiOIPVg== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/recommend@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/recommend/-/recommend-5.25.0.tgz#b96f12c85aa74a0326982c7801fcd4a610b420f4" - integrity sha512-79sMdHpiRLXVxSjgw7Pt4R1aNUHxFLHiaTDnN2MQjHwJ1+o3wSseb55T9VXU4kqy3m7TUme3pyRhLk5ip/S4Mw== +"@algolia/recommend@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/recommend/-/recommend-5.29.0.tgz#8f2e5fe2e43e6d1dfa488b4c095404e46d0e1b0c" + integrity sha512-xte5+mpdfEARAu61KXa4ewpjchoZuJlAlvQb8ptK6hgHlBHDnYooy1bmOFpokaAICrq/H9HpoqNUX71n+3249A== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/requester-browser-xhr@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.25.0.tgz#c194fa5f49206b9343e6646c41bfbca2a3f2ac54" - integrity sha512-JLaF23p1SOPBmfEqozUAgKHQrGl3z/Z5RHbggBu6s07QqXXcazEsub5VLonCxGVqTv6a61AAPr8J1G5HgGGjEw== +"@algolia/requester-browser-xhr@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.29.0.tgz#c3cec914716160d3d972ff09b3b35093916cb5bb" + integrity sha512-og+7Em75aPHhahEUScq2HQ3J7ULN63Levtd87BYMpn6Im5d5cNhaC4QAUsXu6LWqxRPgh4G+i+wIb6tVhDhg2A== dependencies: - "@algolia/client-common" "5.25.0" + "@algolia/client-common" "5.29.0" -"@algolia/requester-fetch@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.25.0.tgz#231a2d0da2397d141f80b8f28e2cb6e3d219d38d" - integrity sha512-rtzXwqzFi1edkOF6sXxq+HhmRKDy7tz84u0o5t1fXwz0cwx+cjpmxu/6OQKTdOJFS92JUYHsG51Iunie7xbqfQ== +"@algolia/requester-fetch@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.29.0.tgz#3d885d73ab116c4c1ae88e7e6fb3b022cba45ce8" + integrity sha512-JCxapz7neAy8hT/nQpCvOrI5JO8VyQ1kPvBiaXWNC1prVq0UMYHEL52o1BsPvtXfdQ7BVq19OIq6TjOI06mV/w== dependencies: - "@algolia/client-common" "5.25.0" + "@algolia/client-common" "5.29.0" -"@algolia/requester-node-http@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.25.0.tgz#0ce13c550890de21c558b04381535d2d245a3725" - integrity sha512-ZO0UKvDyEFvyeJQX0gmZDQEvhLZ2X10K+ps6hViMo1HgE2V8em00SwNsQ+7E/52a+YiBkVWX61pJJJE44juDMQ== +"@algolia/requester-node-http@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.29.0.tgz#9e8fb975c392ba1a99b8774856cfc892ed17819e" + integrity sha512-lVBD81RBW5VTdEYgnzCz7Pf9j2H44aymCP+/eHGJu4vhU+1O8aKf3TVBgbQr5UM6xoe8IkR/B112XY6YIG2vtg== dependencies: - "@algolia/client-common" "5.25.0" + "@algolia/client-common" "5.29.0" "@alloc/quick-lru@^5.2.0": version "5.2.0" @@ -217,9 +217,9 @@ integrity sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw== "@babel/compat-data@^7.27.2": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.3.tgz#cc49c2ac222d69b889bf34c795f537c0c6311111" - integrity sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw== + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.5.tgz#7d0658ec1a8420fc866d1df1b03bea0e79934c82" + integrity sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg== "@babel/core@^7.21.3": version "7.24.7" @@ -243,19 +243,19 @@ semver "^6.3.1" "@babel/core@^7.25.9": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.3.tgz#d7d05502bccede3cab36373ed142e6a1df554c2f" - integrity sha512-hyrN8ivxfvJ4i0fIJuV4EOlV0WDMz5Ui4StRTgVaAvWeiRCilXgwVvxJKtFQ3TKtHgJscB2YiXKGNJuVwhQMtA== + version "7.27.4" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.4.tgz#cc1fc55d0ce140a1828d1dd2a2eba285adbfb3ce" + integrity sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g== dependencies: "@ampproject/remapping" "^2.2.0" "@babel/code-frame" "^7.27.1" "@babel/generator" "^7.27.3" "@babel/helper-compilation-targets" "^7.27.2" "@babel/helper-module-transforms" "^7.27.3" - "@babel/helpers" "^7.27.3" - "@babel/parser" "^7.27.3" + "@babel/helpers" "^7.27.4" + "@babel/parser" "^7.27.4" "@babel/template" "^7.27.2" - "@babel/traverse" "^7.27.3" + "@babel/traverse" "^7.27.4" "@babel/types" "^7.27.3" convert-source-map "^2.0.0" debug "^4.1.0" @@ -274,11 +274,11 @@ jsesc "^2.5.1" "@babel/generator@^7.25.9", "@babel/generator@^7.27.3": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.27.3.tgz#ef1c0f7cfe3b5fc8cbb9f6cc69f93441a68edefc" - integrity sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q== + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.27.5.tgz#3eb01866b345ba261b04911020cbe22dd4be8c8c" + integrity sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw== dependencies: - "@babel/parser" "^7.27.3" + "@babel/parser" "^7.27.5" "@babel/types" "^7.27.3" "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.25" @@ -628,13 +628,13 @@ "@babel/template" "^7.27.0" "@babel/types" "^7.27.0" -"@babel/helpers@^7.27.3": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.27.3.tgz#387d65d279290e22fe7a47a8ffcd2d0c0184edd0" - integrity sha512-h/eKy9agOya1IGuLaZ9tEUgz+uIRXcbtOhRtUyyMf8JFmn1iT13vnl/IGVWSkdOCG/pC57U4S1jnAabAavTMwg== +"@babel/helpers@^7.27.4": + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.27.6.tgz#6456fed15b2cb669d2d1fabe84b66b34991d812c" + integrity sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug== dependencies: "@babel/template" "^7.27.2" - "@babel/types" "^7.27.3" + "@babel/types" "^7.27.6" "@babel/highlight@^7.24.7": version "7.24.7" @@ -658,10 +658,10 @@ dependencies: "@babel/types" "^7.27.0" -"@babel/parser@^7.27.2", "@babel/parser@^7.27.3": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.3.tgz#1b7533f0d908ad2ac545c4d05cbe2fb6dc8cfaaf" - integrity sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw== +"@babel/parser@^7.27.2", "@babel/parser@^7.27.4", "@babel/parser@^7.27.5": + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.5.tgz#ed22f871f110aa285a6fd934a0efed621d118826" + integrity sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg== dependencies: "@babel/types" "^7.27.3" @@ -983,9 +983,9 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-transform-block-scoping@^7.27.1": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.3.tgz#a21f37e222dc0a7b91c3784fa3bd4edf8d7a6dc1" - integrity sha512-+F8CnfhuLhwUACIJMLWnjz6zvzYM2r0yeIHKlbgfw7ml8rOMJsXNXV/hyRcb3nb493gRs4WvYpQAndWj/qQmkQ== + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.5.tgz#98c37485d815533623d992fd149af3e7b3140157" + integrity sha512-JF6uE2s67f0y2RZcm2kpAUEbD50vH62TyWVebxwHAlbSdM49VqPz8t4a1uIjp4NIOIZ4xzLfjY5emt/RCyC7TQ== dependencies: "@babel/helper-plugin-utils" "^7.27.1" @@ -1595,9 +1595,9 @@ regenerator-transform "^0.15.2" "@babel/plugin-transform-regenerator@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.1.tgz#0a471df9213416e44cd66bf67176b66f65768401" - integrity sha512-B19lbbL7PMrKr52BNPjCqg1IyNUIjTcxKj8uX9zHO+PmWN93s19NDr/f69mIkEp2x9nmDJ08a7lgHaTTzvW7mw== + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.5.tgz#0c01f4e0e4cced15f68ee14b9c76dac9813850c7" + integrity sha512-uhB8yHerfe3MWnuLAhEbeQ4afVoqv8BQsPqrTv7e/jZ9y00kJL6l9a/f4OWaKxotmjzewfEyXE1vgDJenkQ2/Q== dependencies: "@babel/helper-plugin-utils" "^7.27.1" @@ -1624,9 +1624,9 @@ "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-runtime@^7.25.9": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.27.3.tgz#ad35f1eff5ba18a5e23f7270e939fb5a59d3ec0b" - integrity sha512-bA9ZL5PW90YwNgGfjg6U+7Qh/k3zCEQJ06BFgAGRp/yMjw9hP9UGbGPtx3KSOkHGljEPCCxaE+PH4fUR2h1sDw== + version "7.27.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.27.4.tgz#dee5c5db6543313d1ae1b4b1ec122ff1e77352b9" + integrity sha512-D68nR5zxU64EUzV8i7T3R5XP0Xhrou/amNnddsRQssx6GrTLdZl1rLxyjtVZBd+v/NVX4AbTPOB5aU8thAZV1A== dependencies: "@babel/helper-module-imports" "^7.27.1" "@babel/helper-plugin-utils" "^7.27.1" @@ -2013,13 +2013,13 @@ integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== "@babel/runtime-corejs3@^7.25.9": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.27.3.tgz#b971a4a0a376171e266629152e74ef50e9931f79" - integrity sha512-ZYcgrwb+dkWNcDlsTe4fH1CMdqMDSJ5lWFd1by8Si2pI54XcQjte/+ViIPqAk7EAWisaUxvQ89grv+bNX2x8zg== + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.27.6.tgz#97644153808a62898e7c05f3361501417db3c48b" + integrity sha512-vDVrlmRAY8z9Ul/HxT+8ceAru95LQgkSKiXkSYZvqtbkPSfhZJgpRp45Cldbh1GJ1kxzQkI70AqyrTI58KpaWQ== dependencies: core-js-pure "^3.30.2" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.20.13", "@babel/runtime@^7.23.2", "@babel/runtime@^7.26.0", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.27.0" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.0.tgz#fbee7cf97c709518ecc1f590984481d5460d4762" integrity sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw== @@ -2027,9 +2027,9 @@ regenerator-runtime "^0.14.0" "@babel/runtime@^7.25.9": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.3.tgz#10491113799fb8d77e1d9273384d5d68deeea8f6" - integrity sha512-7EYtGezsdiDMyY80+65EzwiGmcJqpmcZCojSXaRgdrBaGtWTgDZKq69cPIVped6MkIM78cTQ2GOiEYjwOlG4xw== + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6" + integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q== "@babel/template@^7.24.7": version "7.24.7" @@ -2074,14 +2074,14 @@ debug "^4.3.1" globals "^11.1.0" -"@babel/traverse@^7.25.9", "@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.27.3.tgz#8b62a6c2d10f9d921ba7339c90074708509cffae" - integrity sha512-lId/IfN/Ye1CIu8xG7oKBHXd2iNb2aW1ilPszzGcJug6M8RCKfVNcYhpI5+bMvFYjK7lXIM0R+a+6r8xhHp2FQ== +"@babel/traverse@^7.25.9", "@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3", "@babel/traverse@^7.27.4": + version "7.27.4" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.27.4.tgz#b0045ac7023c8472c3d35effd7cc9ebd638da6ea" + integrity sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA== dependencies: "@babel/code-frame" "^7.27.1" "@babel/generator" "^7.27.3" - "@babel/parser" "^7.27.3" + "@babel/parser" "^7.27.4" "@babel/template" "^7.27.2" "@babel/types" "^7.27.3" debug "^4.3.1" @@ -2104,10 +2104,10 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" -"@babel/types@^7.27.1", "@babel/types@^7.27.3": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.3.tgz#c0257bedf33aad6aad1f406d35c44758321eb3ec" - integrity sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw== +"@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.27.6": + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.6.tgz#a434ca7add514d4e646c80f7375c0aa2befc5535" + integrity sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q== dependencies: "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" @@ -3072,6 +3072,33 @@ resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-5.5.3.tgz#18e3af6b8eae7984072bbeb0c0858474d7c4cefe" integrity sha512-R11tGE6yIFwqpaIqcfkcg7AICXzFg14+5h5v0TfF/9+RMDL6jhzCy/pxHVOfbALGdtVYdt6JdR21tuxEgl34dw== +"@floating-ui/core@^1.6.0": + version "1.6.9" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.9.tgz#64d1da251433019dafa091de9b2886ff35ec14e6" + integrity sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw== + dependencies: + "@floating-ui/utils" "^0.2.9" + +"@floating-ui/dom@^1.0.0": + version "1.6.13" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.13.tgz#a8a938532aea27a95121ec16e667a7cbe8c59e34" + integrity sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w== + dependencies: + "@floating-ui/core" "^1.6.0" + "@floating-ui/utils" "^0.2.9" + +"@floating-ui/react-dom@^2.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.2.tgz#a1349bbf6a0e5cb5ded55d023766f20a4d439a31" + integrity sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A== + dependencies: + "@floating-ui/dom" "^1.0.0" + +"@floating-ui/utils@^0.2.9": + version "0.2.9" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.9.tgz#50dea3616bc8191fb8e112283b49eaff03e78429" + integrity sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg== + "@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" @@ -3121,6 +3148,104 @@ local-pkg "^1.0.0" mlly "^1.7.4" +"@inkeep/cxkit-color-mode@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-color-mode/-/cxkit-color-mode-0.5.89.tgz#4a5471b3dc453262ef0277908c30e108b1095331" + integrity sha512-h89/i67uEiJh0Bqf/dt9nJWv3IjCnmu96nkomocZ3evPKrLeBq13IygFIxtvmvXfx1QBBdmAD+x933rc5RcgFA== + +"@inkeep/cxkit-docusaurus@^0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-docusaurus/-/cxkit-docusaurus-0.5.89.tgz#aff4035fe2bb69401d1677393dcd2943bf3e0d1f" + integrity sha512-z4OxGLoPVbk6ZcKW5i/MrGJUx6Wyc5zh2mxOt2IHTQgFL3XiArCKds0R8jSxSi8SB/a9j5Wm/X0FinT+or9NKA== + dependencies: + "@inkeep/cxkit-react" "0.5.89" + merge-anything "5.1.7" + path "^0.12.7" + +"@inkeep/cxkit-primitives@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-primitives/-/cxkit-primitives-0.5.89.tgz#1f8252f18754aab2c28dd0e1436381b4323d2c0b" + integrity sha512-ugC80ivuimKmzcm8RktAL9C7YHss7CneerbHnNy+uipCx9ZcIY5dRfgMqIdoLG6fL7fhSm6E0QElGL8YjjfN6g== + dependencies: + "@inkeep/cxkit-color-mode" "0.5.89" + "@inkeep/cxkit-theme" "0.5.89" + "@inkeep/cxkit-types" "0.5.89" + "@radix-ui/primitive" "^1.1.1" + "@radix-ui/react-avatar" "1.1.2" + "@radix-ui/react-checkbox" "1.1.3" + "@radix-ui/react-compose-refs" "^1.1.1" + "@radix-ui/react-context" "^1.1.1" + "@radix-ui/react-dismissable-layer" "^1.1.5" + "@radix-ui/react-focus-guards" "^1.1.1" + "@radix-ui/react-focus-scope" "^1.1.2" + "@radix-ui/react-hover-card" "^1.1.6" + "@radix-ui/react-id" "^1.1.0" + "@radix-ui/react-popover" "1.1.6" + "@radix-ui/react-portal" "^1.1.4" + "@radix-ui/react-presence" "^1.1.2" + "@radix-ui/react-primitive" "^2.0.2" + "@radix-ui/react-scroll-area" "1.2.2" + "@radix-ui/react-select" "^2.1.7" + "@radix-ui/react-slot" "^1.2.0" + "@radix-ui/react-tabs" "^1.1.4" + "@radix-ui/react-tooltip" "1.1.6" + "@radix-ui/react-use-callback-ref" "^1.1.0" + "@radix-ui/react-use-controllable-state" "^1.1.0" + "@zag-js/focus-trap" "^1.7.0" + "@zag-js/presence" "^1.13.1" + "@zag-js/react" "^1.13.1" + altcha-lib "^1.2.0" + aria-hidden "^1.2.4" + dequal "^2.0.3" + humps "2.0.1" + lucide-react "^0.503.0" + marked "^15.0.9" + merge-anything "5.1.7" + openai "4.78.1" + prism-react-renderer "2.4.1" + react-error-boundary "^6.0.0" + react-hook-form "7.54.2" + react-markdown "9.0.3" + react-remove-scroll "^2.7.1" + react-svg "16.3.0" + react-textarea-autosize "8.5.7" + rehype-raw "7.0.0" + remark-gfm "^4.0.1" + unist-util-visit "^5.0.0" + use-sync-external-store "^1.4.0" + +"@inkeep/cxkit-react@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-react/-/cxkit-react-0.5.89.tgz#4bc37852bc6161ed4dc5b44b3ceb8beddf49f6f8" + integrity sha512-v86J6xe86kgKfDzlNGZSGHQ3PB8KJ46ra8xnVV4RrRjp7kPGiR7MvGfalFcSiaSEQzWiAcKAl4gya/AF/J/OZw== + dependencies: + "@inkeep/cxkit-styled" "0.5.89" + "@radix-ui/react-use-controllable-state" "^1.1.0" + lucide-react "^0.503.0" + +"@inkeep/cxkit-styled@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-styled/-/cxkit-styled-0.5.89.tgz#e113ee393f5055457281a52cfb8221dd14f70661" + integrity sha512-w9V3vYuq4ytluow16RO/0/V1s9PSBqOjZdvATdn+jy06gUn5ClNAiJ79m34fB3Ep0Y6o2m+obujX4njw4A+LPw== + dependencies: + "@inkeep/cxkit-primitives" "0.5.89" + class-variance-authority "0.7.1" + clsx "2.1.1" + merge-anything "5.1.7" + tailwind-merge "2.6.0" + +"@inkeep/cxkit-theme@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-theme/-/cxkit-theme-0.5.89.tgz#b1f9f7be2a87f25b8c6b2c4eb654b998b6bd7cc8" + integrity sha512-Yji2OCDi2buYZQXY4tw93U6W3ZFDaNw7wgGLP+vTyRZaFBMGwW52zNOjewz2UL1jNGl6ublHXmWM+kjIC4b5SQ== + dependencies: + colorjs.io "0.5.2" + +"@inkeep/cxkit-types@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-types/-/cxkit-types-0.5.89.tgz#f8db85cca7c8dbb72c6a035882435b5e0e86ca76" + integrity sha512-zz6945Ex9kSpIUeZaVAX4h6HeCaOt28BzZyuprQWXIpzvAlFKuKDNV1Zm5umEglaiGSx+T9J6WViy3PoRXwTtA== + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -3238,10 +3363,10 @@ dependencies: "@types/mdx" "^2.0.0" -"@mermaid-js/parser@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.4.0.tgz#c1de1f5669f8fcbd0d0c9d124927d36ddc00d8a6" - integrity sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA== +"@mermaid-js/parser@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.5.0.tgz#63d676e930b0cfd6abfeadee46fb228761438ce6" + integrity sha512-AiaN7+VjXC+3BYE+GwNezkpjIcCI2qIMB/K4S2/vMWe0q/XJCBbx5+K7iteuz7VyltX9iAK4FmVTvGc9kjOV4w== dependencies: langium "3.3.1" @@ -3429,6 +3554,551 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.25.tgz#f077fdc0b5d0078d30893396ff4827a13f99e817" integrity sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ== +"@radix-ui/number@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.0.tgz#1e95610461a09cdf8bb05c152e76ca1278d5da46" + integrity sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ== + +"@radix-ui/number@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090" + integrity sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g== + +"@radix-ui/primitive@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.1.tgz#fc169732d755c7fbad33ba8d0cd7fd10c90dc8e3" + integrity sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA== + +"@radix-ui/primitive@1.1.2", "@radix-ui/primitive@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.2.tgz#83f415c4425f21e3d27914c12b3272a32e3dae65" + integrity sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA== + +"@radix-ui/react-arrow@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz#2103721933a8bfc6e53bbfbdc1aaad5fc8ba0dd7" + integrity sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w== + dependencies: + "@radix-ui/react-primitive" "2.0.1" + +"@radix-ui/react-arrow@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz#30c0d574d7bb10eed55cd7007b92d38b03c6b2ab" + integrity sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg== + dependencies: + "@radix-ui/react-primitive" "2.0.2" + +"@radix-ui/react-arrow@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.3.tgz#8926eb1d87f73c2e047eac96703949f168c85861" + integrity sha512-2dvVU4jva0qkNZH6HHWuSz5FN5GeU5tymvCgutF8WaXz9WnD1NgUhy73cqzkjkN4Zkn8lfTPv5JIfrC221W+Nw== + dependencies: + "@radix-ui/react-primitive" "2.0.3" + +"@radix-ui/react-avatar@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-avatar/-/react-avatar-1.1.2.tgz#24af4c66bb5271460a4a6b74c4f4f9d4789d3d90" + integrity sha512-GaC7bXQZ5VgZvVvsJ5mu/AEbjYLnhhkoidOboC50Z6FFlLA03wG2ianUoH+zgDQ31/9gCF59bE4+2bBgTyMiig== + dependencies: + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-checkbox@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-checkbox/-/react-checkbox-1.1.3.tgz#0e2ab913fddf3c88603625f7a9457d73882c8a32" + integrity sha512-HD7/ocp8f1B3e6OHygH0n7ZKjONkhciy1Nh0yuBgObqThc3oyx+vuMfFHKAknXRHHWVE9XvXStxJFyjUmB8PIw== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-use-previous" "1.1.0" + "@radix-ui/react-use-size" "1.1.0" + +"@radix-ui/react-collection@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.3.tgz#cfd46dcea5a8ab064d91798feeb46faba4032930" + integrity sha512-mM2pxoQw5HJ49rkzwOs7Y6J4oYH22wS8BfK2/bBxROlI4xuR0c4jEenQP63LlTlDkO6Buj2Vt+QYAYcOgqtrXA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-slot" "1.2.0" + +"@radix-ui/react-compose-refs@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz#6f766faa975f8738269ebb8a23bad4f5a8d2faec" + integrity sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw== + +"@radix-ui/react-compose-refs@1.1.2", "@radix-ui/react-compose-refs@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30" + integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg== + +"@radix-ui/react-context@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.1.tgz#82074aa83a472353bb22e86f11bcbd1c61c4c71a" + integrity sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q== + +"@radix-ui/react-context@1.1.2", "@radix-ui/react-context@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36" + integrity sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA== + +"@radix-ui/react-direction@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.0.tgz#a7d39855f4d077adc2a1922f9c353c5977a09cdc" + integrity sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg== + +"@radix-ui/react-direction@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz#39e5a5769e676c753204b792fbe6cf508e550a14" + integrity sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw== + +"@radix-ui/react-dismissable-layer@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz#4ee0f0f82d53bf5bd9db21665799bb0d1bad5ed8" + integrity sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-escape-keydown" "1.1.0" + +"@radix-ui/react-dismissable-layer@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz#96dde2be078c694a621e55e047406c58cd5fe774" + integrity sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-escape-keydown" "1.1.0" + +"@radix-ui/react-dismissable-layer@1.1.6", "@radix-ui/react-dismissable-layer@^1.1.5": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.6.tgz#e72c156cac7b07614fe8e3a039ab7081ce413686" + integrity sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-escape-keydown" "1.1.1" + +"@radix-ui/react-focus-guards@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz#8635edd346304f8b42cae86b05912b61aef27afe" + integrity sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg== + +"@radix-ui/react-focus-guards@1.1.2", "@radix-ui/react-focus-guards@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz#4ec9a7e50925f7fb661394460045b46212a33bed" + integrity sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA== + +"@radix-ui/react-focus-scope@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz#c0a4519cd95c772606a82fc5b96226cd7fdd2602" + integrity sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-callback-ref" "1.1.0" + +"@radix-ui/react-focus-scope@1.1.3", "@radix-ui/react-focus-scope@^1.1.2": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.3.tgz#eac83a3aac700db17650b41b30724deffac5b28a" + integrity sha512-4XaDlq0bPt7oJwR+0k0clCiCO/7lO7NKZTAaJBYxDNQT/vj4ig0/UvctrRscZaFREpRvUTkpKR96ov1e6jptQg== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + +"@radix-ui/react-hover-card@^1.1.6": + version "1.1.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-hover-card/-/react-hover-card-1.1.7.tgz#01b2f956daeb8a1193ccdb36c9c00943120bf2d4" + integrity sha512-HwM03kP8psrv21J1+9T/hhxi0f5rARVbqIZl9+IAq13l4j4fX+oGIuxisukZZmebO7J35w9gpoILvtG8bbph0w== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-dismissable-layer" "1.1.6" + "@radix-ui/react-popper" "1.2.3" + "@radix-ui/react-portal" "1.1.5" + "@radix-ui/react-presence" "1.1.3" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-controllable-state" "1.1.1" + +"@radix-ui/react-id@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.0.tgz#de47339656594ad722eb87f94a6b25f9cffae0ed" + integrity sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-id@1.1.1", "@radix-ui/react-id@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.1.tgz#1404002e79a03fe062b7e3864aa01e24bd1471f7" + integrity sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-popover@1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.6.tgz#699634dbc7899429f657bb590d71fb3ca0904087" + integrity sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.5" + "@radix-ui/react-focus-guards" "1.1.1" + "@radix-ui/react-focus-scope" "1.1.2" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-popper" "1.2.2" + "@radix-ui/react-portal" "1.1.4" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-slot" "1.1.2" + "@radix-ui/react-use-controllable-state" "1.1.0" + aria-hidden "^1.2.4" + react-remove-scroll "^2.6.3" + +"@radix-ui/react-popper@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.1.tgz#2fc66cfc34f95f00d858924e3bee54beae2dff0a" + integrity sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw== + dependencies: + "@floating-ui/react-dom" "^2.0.0" + "@radix-ui/react-arrow" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-use-rect" "1.1.0" + "@radix-ui/react-use-size" "1.1.0" + "@radix-ui/rect" "1.1.0" + +"@radix-ui/react-popper@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.2.tgz#d2e1ee5a9b24419c5936a1b7f6f472b7b412b029" + integrity sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA== + dependencies: + "@floating-ui/react-dom" "^2.0.0" + "@radix-ui/react-arrow" "1.1.2" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-use-rect" "1.1.0" + "@radix-ui/react-use-size" "1.1.0" + "@radix-ui/rect" "1.1.0" + +"@radix-ui/react-popper@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.3.tgz#3b6ef3388fd209bb46341e1e40125b75f07f1304" + integrity sha512-iNb9LYUMkne9zIahukgQmHlSBp9XWGeQQ7FvUGNk45ywzOb6kQa+Ca38OphXlWDiKvyneo9S+KSJsLfLt8812A== + dependencies: + "@floating-ui/react-dom" "^2.0.0" + "@radix-ui/react-arrow" "1.1.3" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-layout-effect" "1.1.1" + "@radix-ui/react-use-rect" "1.1.1" + "@radix-ui/react-use-size" "1.1.1" + "@radix-ui/rect" "1.1.1" + +"@radix-ui/react-portal@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.3.tgz#b0ea5141103a1671b715481b13440763d2ac4440" + integrity sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw== + dependencies: + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-portal@1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.4.tgz#ff5401ff63c8a825c46eea96d3aef66074b8c0c8" + integrity sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA== + dependencies: + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-portal@1.1.5", "@radix-ui/react-portal@^1.1.4": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.5.tgz#50ed6bee2d895c9a9dfc28625f24b8483b74d431" + integrity sha512-ps/67ZqsFm+Mb6lSPJpfhRLrVL2i2fntgCmGMqqth4eaGUf+knAuuRtWVJrNjUhExgmdRqftSgzpf0DF0n6yXA== + dependencies: + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-presence@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.2.tgz#bb764ed8a9118b7ec4512da5ece306ded8703cdc" + integrity sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-presence@1.1.3", "@radix-ui/react-presence@^1.1.2": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.3.tgz#ce3400caec9892ceb862f96ddaa2add080c09b90" + integrity sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-primitive@2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz#6d9efc550f7520135366f333d1e820cf225fad9e" + integrity sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg== + dependencies: + "@radix-ui/react-slot" "1.1.1" + +"@radix-ui/react-primitive@2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz#ac8b7854d87b0d7af388d058268d9a7eb64ca8ef" + integrity sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w== + dependencies: + "@radix-ui/react-slot" "1.1.2" + +"@radix-ui/react-primitive@2.0.3", "@radix-ui/react-primitive@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz#13c654dc4754558870a9c769f6febe5980a1bad8" + integrity sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g== + dependencies: + "@radix-ui/react-slot" "1.2.0" + +"@radix-ui/react-roving-focus@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.3.tgz#c992b9d30c795f5f5a668853db8f4a6e07b7284d" + integrity sha512-ufbpLUjZiOg4iYgb2hQrWXEPYX6jOLBbR27bDyAff5GYMRrCzcze8lukjuXVUQvJ6HZe8+oL+hhswDcjmcgVyg== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-collection" "1.1.3" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.1.1" + +"@radix-ui/react-scroll-area@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.2.tgz#28e34fd4d83e9de5d987c5e8914a7bd8be9546a5" + integrity sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g== + dependencies: + "@radix-ui/number" "1.1.0" + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-direction" "1.1.0" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-select@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-select/-/react-select-2.1.7.tgz#68561488ca54cad07352b3f2c2d29e0da28bbaa0" + integrity sha512-exzGIRtc7S8EIM2KjFg+7lJZsH7O7tpaBaJbBNVDnOZNhtoQ2iV+iSNfi2Wth0m6h3trJkMVvzAehB3c6xj/3Q== + dependencies: + "@radix-ui/number" "1.1.1" + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-collection" "1.1.3" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.6" + "@radix-ui/react-focus-guards" "1.1.2" + "@radix-ui/react-focus-scope" "1.1.3" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-popper" "1.2.3" + "@radix-ui/react-portal" "1.1.5" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-slot" "1.2.0" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.1.1" + "@radix-ui/react-use-layout-effect" "1.1.1" + "@radix-ui/react-use-previous" "1.1.1" + "@radix-ui/react-visually-hidden" "1.1.3" + aria-hidden "^1.2.4" + react-remove-scroll "^2.6.3" + +"@radix-ui/react-slot@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.1.tgz#ab9a0ffae4027db7dc2af503c223c978706affc3" + integrity sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + +"@radix-ui/react-slot@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.2.tgz#daffff7b2bfe99ade63b5168407680b93c00e1c6" + integrity sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + +"@radix-ui/react-slot@1.2.0", "@radix-ui/react-slot@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.0.tgz#57727fc186ddb40724ccfbe294e1a351d92462ba" + integrity sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + +"@radix-ui/react-tabs@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.4.tgz#2e43f3ef3450143281e7c1491da1e5d7941b9826" + integrity sha512-fuHMHWSf5SRhXke+DbHXj2wVMo+ghVH30vhX3XVacdXqDl+J4XWafMIGOOER861QpBx1jxgwKXL2dQnfrsd8MQ== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-presence" "1.1.3" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-roving-focus" "1.1.3" + "@radix-ui/react-use-controllable-state" "1.1.1" + +"@radix-ui/react-tooltip@1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz#eab98e9a5c876ef0abfae3cfeee229870528ed06" + integrity sha512-TLB5D8QLExS1uDn7+wH/bjEmRurNMTzNrtq7IjaS4kjion9NtzsTGkvR5+i7yc9q01Pi2KMM2cN3f8UG4IvvXA== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.3" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-popper" "1.2.1" + "@radix-ui/react-portal" "1.1.3" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-slot" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-visually-hidden" "1.1.1" + +"@radix-ui/react-use-callback-ref@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1" + integrity sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw== + +"@radix-ui/react-use-callback-ref@1.1.1", "@radix-ui/react-use-callback-ref@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz#62a4dba8b3255fdc5cc7787faeac1c6e4cc58d40" + integrity sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg== + +"@radix-ui/react-use-controllable-state@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz#1321446857bb786917df54c0d4d084877aab04b0" + integrity sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.0" + +"@radix-ui/react-use-controllable-state@1.1.1", "@radix-ui/react-use-controllable-state@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.1.tgz#ec9c572072a6f269df7435c1652fbeebabe0f0c1" + integrity sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.1" + +"@radix-ui/react-use-escape-keydown@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz#31a5b87c3b726504b74e05dac1edce7437b98754" + integrity sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.0" + +"@radix-ui/react-use-escape-keydown@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz#b3fed9bbea366a118f40427ac40500aa1423cc29" + integrity sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.1" + +"@radix-ui/react-use-layout-effect@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz#3c2c8ce04827b26a39e442ff4888d9212268bd27" + integrity sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w== + +"@radix-ui/react-use-layout-effect@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e" + integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ== + +"@radix-ui/react-use-previous@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz#d4dd37b05520f1d996a384eb469320c2ada8377c" + integrity sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og== + +"@radix-ui/react-use-previous@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz#1a1ad5568973d24051ed0af687766f6c7cb9b5b5" + integrity sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ== + +"@radix-ui/react-use-rect@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz#13b25b913bd3e3987cc9b073a1a164bb1cf47b88" + integrity sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ== + dependencies: + "@radix-ui/rect" "1.1.0" + +"@radix-ui/react-use-rect@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz#01443ca8ed071d33023c1113e5173b5ed8769152" + integrity sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w== + dependencies: + "@radix-ui/rect" "1.1.1" + +"@radix-ui/react-use-size@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz#b4dba7fbd3882ee09e8d2a44a3eed3a7e555246b" + integrity sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-use-size@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz#6de276ffbc389a537ffe4316f5b0f24129405b37" + integrity sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-visually-hidden@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.1.tgz#f7b48c1af50dfdc366e92726aee6d591996c5752" + integrity sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg== + dependencies: + "@radix-ui/react-primitive" "2.0.1" + +"@radix-ui/react-visually-hidden@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.3.tgz#f704c49121859941a8bb50ff1e4f156058cacd0b" + integrity sha512-oXSF3ZQRd5fvomd9hmUCb2EHSZbPp3ZSHAHJJU/DlF9XoFkJBBW8RHU/E8WEH+RbSfJd/QFA0sl8ClJXknBwHQ== + dependencies: + "@radix-ui/react-primitive" "2.0.3" + +"@radix-ui/rect@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438" + integrity sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg== + +"@radix-ui/rect@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb" + integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw== + "@redocly/ajv@^8.11.0": version "8.11.0" resolved "https://registry.yarnpkg.com/@redocly/ajv/-/ajv-8.11.0.tgz#2fad322888dc0113af026e08fceb3e71aae495ae" @@ -3691,152 +4361,152 @@ "@svgr/plugin-jsx" "8.1.0" "@svgr/plugin-svgo" "8.1.0" -"@swc/core-darwin-arm64@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.29.tgz#bf66e3f4f00e6fe9d95e8a33f780e6c40fca946d" - integrity sha512-whsCX7URzbuS5aET58c75Dloby3Gtj/ITk2vc4WW6pSDQKSPDuONsIcZ7B2ng8oz0K6ttbi4p3H/PNPQLJ4maQ== +"@swc/core-darwin-arm64@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.12.6.tgz#3d9166720df2dc00fa3b6cf90fce3e77a442a43e" + integrity sha512-yLiw+XzG+MilfFh0ON7qt67bfIr7UxB9JprhYReVOmLTBDmDVQSC3T4/vIuc+GwlX08ydnHy0ud4lIjTNW4uWg== -"@swc/core-darwin-x64@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.11.29.tgz#0a77d2d79ef2c789f9d40a86784bbf52c5f9877f" - integrity sha512-S3eTo/KYFk+76cWJRgX30hylN5XkSmjYtCBnM4jPLYn7L6zWYEPajsFLmruQEiTEDUg0gBEWLMNyUeghtswouw== +"@swc/core-darwin-x64@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.12.6.tgz#2bbc32c56f8bccf6958b73d46bdd5670aa31f4d9" + integrity sha512-qwg8ux5x5Gd1LmSUtL4s9mbyfzAjr5M6OtjO281dKHwc/GYiSc4j1urb2jNSo9FcMkfT78oAOW2L6HQiWv+j1A== -"@swc/core-linux-arm-gnueabihf@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.29.tgz#80fa3a6a36034ffdbbba73e26c8f27cb13111a33" - integrity sha512-o9gdshbzkUMG6azldHdmKklcfrcMx+a23d/2qHQHPDLUPAN+Trd+sDQUYArK5Fcm7TlpG4sczz95ghN0DMkM7g== +"@swc/core-linux-arm-gnueabihf@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.12.6.tgz#45d8bdef987c4f7fbc5b14640374f0e77904f304" + integrity sha512-pnkqH59JXBZu+MedaykMAC2or7tlUKeya7GKjzub+hkwxBP0ywWoFd+QYEdzp7QSziOt1VIHc4Wb9iZ2EfnzmA== -"@swc/core-linux-arm64-gnu@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.29.tgz#42da87f445bc3e26da01d494246884006d9b9a1a" - integrity sha512-sLoaciOgUKQF1KX9T6hPGzvhOQaJn+3DHy4LOHeXhQqvBgr+7QcZ+hl4uixPKTzxk6hy6Hb0QOvQEdBAAR1gXw== +"@swc/core-linux-arm64-gnu@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.12.6.tgz#f69616d4269e11cb93348437687e08f49f58cc36" + integrity sha512-h8+Ltx0NSEzIFHetkOYoQ+UQ59unYLuJ4wF6kCpxzS4HskRLjcngr1HgN0F/PRpptnrmJUPVQmfms/vjN8ndAQ== -"@swc/core-linux-arm64-musl@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.29.tgz#c9cec610525dc9e9b11ef26319db3780812dfa54" - integrity sha512-PwjB10BC0N+Ce7RU/L23eYch6lXFHz7r3NFavIcwDNa/AAqywfxyxh13OeRy+P0cg7NDpWEETWspXeI4Ek8otw== +"@swc/core-linux-arm64-musl@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.12.6.tgz#3277154e7b60213c0fa11e415c8a90b41563d76e" + integrity sha512-GZu3MnB/5qtBxKEH46hgVDaplEe4mp3ZmQ1O2UpFCv/u/Ji3Gar5w5g2wHCZoT5AOouAhP1bh7IAEqjG/fbVfg== -"@swc/core-linux-x64-gnu@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.29.tgz#1cda2df38a4ab8905ba6ac3aa16e4ad710b6f2de" - integrity sha512-i62vBVoPaVe9A3mc6gJG07n0/e7FVeAvdD9uzZTtGLiuIfVfIBta8EMquzvf+POLycSk79Z6lRhGPZPJPYiQaA== +"@swc/core-linux-x64-gnu@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.12.6.tgz#7d04d9437011396bf54c4ab0686c4db65a1283f8" + integrity sha512-WwJLQFzMW9ufVjM6k3le4HUgBFNunyt2oghjcgn2YjnKj0Ka2LrrBHCxfS7lgFSCQh/shib2wIlKXUnlTEWQJw== -"@swc/core-linux-x64-musl@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.29.tgz#5d634efff33f47c8d6addd84291ab606903d1cfd" - integrity sha512-YER0XU1xqFdK0hKkfSVX1YIyCvMDI7K07GIpefPvcfyNGs38AXKhb2byySDjbVxkdl4dycaxxhRyhQ2gKSlsFQ== +"@swc/core-linux-x64-musl@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.12.6.tgz#2f3e586eda3ee5d08b6b8de7714623d5a44ceb3c" + integrity sha512-rVGPNpI/sm8VVAhnB09Z/23OJP3ymouv6F4z4aYDbq/2JIwxqgpnl8gtMYP+Jw3XqabaFNjQmPiL15TvKCQaxQ== -"@swc/core-win32-arm64-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.29.tgz#bc54f2e3f8f180113b7a092b1ee1eaaab24df62b" - integrity sha512-po+WHw+k9g6FAg5IJ+sMwtA/fIUL3zPQ4m/uJgONBATCVnDDkyW6dBA49uHNVtSEvjvhuD8DVWdFP847YTcITw== +"@swc/core-win32-arm64-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.12.6.tgz#70211534238ed941efaf0a1b34310e937bd4afa7" + integrity sha512-EKDJ1+8vaIlJGMl2yvd2HklV4GNbpKKwNQcUQid6j91tFYz4/aByw+9vh/sDVG7ZNqdmdywSnLRo317UTt0zFg== -"@swc/core-win32-ia32-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.29.tgz#f1df344c06283643d1fe66c6931b350347b73722" - integrity sha512-h+NjOrbqdRBYr5ItmStmQt6x3tnhqgwbj9YxdGPepbTDamFv7vFnhZR0YfB3jz3UKJ8H3uGJ65Zw1VsC+xpFkg== +"@swc/core-win32-ia32-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.12.6.tgz#d3584da07b47904b547baced8b00c9e4d32110e7" + integrity sha512-jnULikZkR2fpZgFUQs7NsNIztavM1JdX+8Y+8FsfChBvMvziKxXtvUPGjeVJ8nzU1wgMnaeilJX9vrwuDGkA0Q== -"@swc/core-win32-x64-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.29.tgz#a6f9dc1df66c8db96d70091abedd78cc52544724" - integrity sha512-Q8cs2BDV9wqDvqobkXOYdC+pLUSEpX/KvI0Dgfun1F+LzuLotRFuDhrvkU9ETJA6OnD2+Fn/ieHgloiKA/Mn/g== +"@swc/core-win32-x64-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.12.6.tgz#8eff8c0d9e7c3b91bd9427e67483be90070b0f7d" + integrity sha512-jL2Dcdcc/QZiQnwByP1uIE4k/mTlapzUng7owtLD2tSBBi1d+jPIdXIefdv+nccYJKRA+lKG3rRB6Tk9GrC7Kg== "@swc/core@^1.7.39": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.11.29.tgz#bce20113c47fcd6251d06262b8b8c063f8e86a20" - integrity sha512-g4mThMIpWbNhV8G2rWp5a5/Igv8/2UFRJx2yImrLGMgrDDYZIopqZ/z0jZxDgqNA1QDx93rpwNF7jGsxVWcMlA== + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.12.6.tgz#1bf204f7afc59fde6cb2cef067de23af232d6ff6" + integrity sha512-TEpta6Gi02X1b2yDIzBOIr7dFprvq9jD8RbEVI2OcMrwklbCUx0Dz9TrAnklSOwRvYvH5JjCx8ht9E94oWiG7A== dependencies: "@swc/counter" "^0.1.3" - "@swc/types" "^0.1.21" + "@swc/types" "^0.1.23" optionalDependencies: - "@swc/core-darwin-arm64" "1.11.29" - "@swc/core-darwin-x64" "1.11.29" - "@swc/core-linux-arm-gnueabihf" "1.11.29" - "@swc/core-linux-arm64-gnu" "1.11.29" - "@swc/core-linux-arm64-musl" "1.11.29" - "@swc/core-linux-x64-gnu" "1.11.29" - "@swc/core-linux-x64-musl" "1.11.29" - "@swc/core-win32-arm64-msvc" "1.11.29" - "@swc/core-win32-ia32-msvc" "1.11.29" - "@swc/core-win32-x64-msvc" "1.11.29" + "@swc/core-darwin-arm64" "1.12.6" + "@swc/core-darwin-x64" "1.12.6" + "@swc/core-linux-arm-gnueabihf" "1.12.6" + "@swc/core-linux-arm64-gnu" "1.12.6" + "@swc/core-linux-arm64-musl" "1.12.6" + "@swc/core-linux-x64-gnu" "1.12.6" + "@swc/core-linux-x64-musl" "1.12.6" + "@swc/core-win32-arm64-msvc" "1.12.6" + "@swc/core-win32-ia32-msvc" "1.12.6" + "@swc/core-win32-x64-msvc" "1.12.6" "@swc/counter@^0.1.3": version "0.1.3" resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== -"@swc/html-darwin-arm64@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-darwin-arm64/-/html-darwin-arm64-1.11.29.tgz#7bd6d10115ffe155ecd757387b5aff318b02b5a0" - integrity sha512-q53kn/HI0n/+pecsOB2gxqITbRAhtBG7VI520SIWuCGXHPsTQ/1VOrhLMNvyfw1xVhRyFal7BpAvfGUORCl0sw== +"@swc/html-darwin-arm64@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-darwin-arm64/-/html-darwin-arm64-1.12.6.tgz#dff9ee656cd1a4ac0a4a2637c4e9a058ce64b42a" + integrity sha512-McW4JsF5wFB5KmHyAaty94kw2hHLbYtrIQvVlshbXM3lpY+rDO0KnS74CcIiAD46p7knV0Y6Xuhint8K3rYfkg== -"@swc/html-darwin-x64@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-darwin-x64/-/html-darwin-x64-1.11.29.tgz#ccafed56081932ffaa51be1788cef88eb9a144d1" - integrity sha512-YfQPjh5WoDqOxsA7vDOOSnxEPc1Ki4SuZ0ufR4t8jYdMOFsU3AhZQ/sgBZLpTzegBTutUn7/7yy8VSoFngeR7Q== +"@swc/html-darwin-x64@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-darwin-x64/-/html-darwin-x64-1.12.6.tgz#194456c256cb5f949af24cfaf1740fb20098285a" + integrity sha512-Fh/bPNdnSNeJ7GrRAe/BqERWV9hbIyZktoMlvkMipz2NPTdadIwXjd8fscVDc6S5j1DigiSp2Mnf0rZgH6Xnhw== -"@swc/html-linux-arm-gnueabihf@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.11.29.tgz#e784a1a0f69034e9dd52a9019ff80f0d5eb91433" - integrity sha512-dC3aEv1mqAUkY9TiZWOE2IcYpvxJzw0LdvkDzGW5072JSlZZYQMqq2Llwg63LIp6qBlj1JLHMLnBqk7Ubatmjw== +"@swc/html-linux-arm-gnueabihf@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.12.6.tgz#aeeeb4b27c06d0f813bbebd6ed48e471fb5873b8" + integrity sha512-F0Z2Fmvdw4vTmmJyFZaGMklrZkrtT9A5d8K1Ez2f7SZwhU09e2cgi49PCHL7wBfc5MBItnugdVJKYi/V6O/Jsg== -"@swc/html-linux-arm64-gnu@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.11.29.tgz#ef015d81d3a011d6c273a428eee130b3aac790b7" - integrity sha512-seo+lCiBUggTR9NsHE4qVC+7+XIfLHK7yxWiIsXb8nNAXDcqVZ0Rxv8O1Y1GTeJfUlcCt1koahCG2AeyWpYFBg== +"@swc/html-linux-arm64-gnu@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.12.6.tgz#5aaf98ff9bced40c70a8662dde73db3a1bf21991" + integrity sha512-2S9hXG5EvDMHdjeiVANft+mZ+dRUrqUqKEAM0GehxsnG/ITT4uTolI3u/upMo7t1leOMWcz85hJZqDbVtfyP5Q== -"@swc/html-linux-arm64-musl@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.11.29.tgz#b20e4b442287367c4c1d62db2b8065106542f432" - integrity sha512-bK8K6t3hHgaZZ1vMNaZ+8x42EWJPEX1Dx4zi6ulMhKa1uan+DjW5SiMlUg0an16fFSYfE+r9oFC4cFEbGP1o4Q== +"@swc/html-linux-arm64-musl@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.12.6.tgz#7f658b07b41ca7910d1af980988549d638666ebc" + integrity sha512-RqKvGk4V2HpEObFva1AbhhEpvH8VrRI1sRjHZW7I0kTWZZkg13tJXSmGhIAfUgJFGWvvVSoZ/8TSyRq8Ju6Pvw== -"@swc/html-linux-x64-gnu@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.11.29.tgz#c45505b3e22c02dc8bdef8b3da48ba855527a62c" - integrity sha512-34tSms5TkRUCr+J6uuSE/11ECcfIpp5R1ODuIgxZRUd/u88pQGKzLVNLWGPLw4b3cZSjnAn+PFJl7BtaYl0UyQ== +"@swc/html-linux-x64-gnu@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.12.6.tgz#b32b9f6fa65ef322d499e35ac0d1599ec425ad93" + integrity sha512-nZzjhrya4VFfT2jX2EYe+FF1EzeghHAB5wyOASFN35CxOpJMhr/04COu5uRggZGYD+19s1LrLelKhSOBAPDrOw== -"@swc/html-linux-x64-musl@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.11.29.tgz#86116e7db3cf02b1a627bc94c374d26ce6f9d68a" - integrity sha512-oJLLrX94ccaniWdQt8PH6K2u8aN/ehBo/YPg84LycFtaud/k73Fa1kh6Neq8vbWI4CugIWTl4LXWoHm+l+QYeA== +"@swc/html-linux-x64-musl@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.12.6.tgz#ac03730723528385f71b2a30b635002eea4f23e1" + integrity sha512-hJdSZw5lo+Ws355gs6M2cV4QTbRmc6Ide7kUYMoSQQFyZVc05am2sulPLfOTHmzV8BW3QZ3apO7wcKTzJ/yBbQ== -"@swc/html-win32-arm64-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.11.29.tgz#cf7e57b8c0b52f7f93abc307b0cb78d8213b3c13" - integrity sha512-nw4TCFfA4YV6jicRdicJZPKW+ihOZPMKEG/4bj1/6HqXw1T2pXI070ASOLE0KOHYuoyV/jConEHfIjlU0olneA== +"@swc/html-win32-arm64-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.12.6.tgz#10fd59442feda8e3d0ce6dfccd3bef6fc1e01440" + integrity sha512-l7kFWXr4/A5joeJBSft8oGMVxXOORu6oKMSNk0SU9kFlSaqmQM9sFXW8Mny7P5bvJoNb/fGjnJ3o7BmSjwu3ow== -"@swc/html-win32-ia32-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.11.29.tgz#61c91409c3fcdf942891c02aa2a2eac1892d1907" - integrity sha512-rO6X4qOofGpKV8pyZ7VblJn+J3PHEqeWHJkJfzwP7c04Flr1oLyuLbTU8lwf8enXrTAZqitHZs+OpofKcUwHEw== +"@swc/html-win32-ia32-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.12.6.tgz#aaa89f639059c1263855c571d6678ca8c5ce8e8a" + integrity sha512-4PQysHukXaGUbP9af6DdqEIuNHMShUj5xQrVZ9M/JNV77JuX8RhTTc8Nq4IzGvCepS77gJnKg2nUbKEOt0vHaQ== -"@swc/html-win32-x64-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.11.29.tgz#0e78b507d2bf28315487655852008cdecfe84535" - integrity sha512-GSCihzBItEPJAeLzkAtw0ZGbxRGMsGt1Z1ugo0uHva1R3Eybkqu9qoax1tGAON+EJzeiHRqphhNgh8MVDpnKnQ== +"@swc/html-win32-x64-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.12.6.tgz#632091341d39231c813fab1beabab4408d6ffe33" + integrity sha512-dNg1qIzriAUQkSwWQP+b7GK09zU126VYt9Eng4RlLzdvZYO1EWrnvTLGgvAADyLk8ELvrDUJ1joaaKzFzZXVOQ== "@swc/html@^1.7.39": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html/-/html-1.11.29.tgz#6e9e1b8ea65baa0d6f25cb883565a5e7d22d2858" - integrity sha512-Tsk/o6Eo3lDvHPGjLqVwXGEdC1bemGzByPWx/TrF5N7qEsanRblPeRcJzLl6LbWa80pRYIRB6T4VqdXXZqklaw== + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html/-/html-1.12.6.tgz#faf01ad0594287680bdb495b424da0f232f81216" + integrity sha512-Qki6Ci6f16BWJhEz5gNB/2QAsSIYvvIjLYUNsrmo1P//By7SF42oDZcu7jPLpsdlMK+qGH9n37be+HZFj9Zn5w== dependencies: "@swc/counter" "^0.1.3" optionalDependencies: - "@swc/html-darwin-arm64" "1.11.29" - "@swc/html-darwin-x64" "1.11.29" - "@swc/html-linux-arm-gnueabihf" "1.11.29" - "@swc/html-linux-arm64-gnu" "1.11.29" - "@swc/html-linux-arm64-musl" "1.11.29" - "@swc/html-linux-x64-gnu" "1.11.29" - "@swc/html-linux-x64-musl" "1.11.29" - "@swc/html-win32-arm64-msvc" "1.11.29" - "@swc/html-win32-ia32-msvc" "1.11.29" - "@swc/html-win32-x64-msvc" "1.11.29" + "@swc/html-darwin-arm64" "1.12.6" + "@swc/html-darwin-x64" "1.12.6" + "@swc/html-linux-arm-gnueabihf" "1.12.6" + "@swc/html-linux-arm64-gnu" "1.12.6" + "@swc/html-linux-arm64-musl" "1.12.6" + "@swc/html-linux-x64-gnu" "1.12.6" + "@swc/html-linux-x64-musl" "1.12.6" + "@swc/html-win32-arm64-msvc" "1.12.6" + "@swc/html-win32-ia32-msvc" "1.12.6" + "@swc/html-win32-x64-msvc" "1.12.6" -"@swc/types@^0.1.21": - version "0.1.21" - resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.21.tgz#6fcadbeca1d8bc89e1ab3de4948cef12344a38c0" - integrity sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ== +"@swc/types@^0.1.23": + version "0.1.23" + resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.23.tgz#7eabf88b9cfd929253859c562ae95982ee04b4e8" + integrity sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw== dependencies: "@swc/counter" "^0.1.3" @@ -3847,6 +4517,15 @@ dependencies: defer-to-connect "^2.0.1" +"@tanem/svg-injector@^10.1.68": + version "10.1.68" + resolved "https://registry.yarnpkg.com/@tanem/svg-injector/-/svg-injector-10.1.68.tgz#0bd08da3c4184b055a6fe16909037c96f49e3cd1" + integrity sha512-UkJajeR44u73ujtr5GVSbIlELDWD/mzjqWe54YMK61ljKxFcJoPd9RBSaO7xj02ISCWUqJW99GjrS+sVF0UnrA== + dependencies: + "@babel/runtime" "^7.23.2" + content-type "^1.0.5" + tslib "^2.6.2" + "@tanstack/react-virtual@^3.0.0-beta.60": version "3.5.1" resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.5.1.tgz#1ce466f530a10f781871360ed2bf7ff83e664f85" @@ -4147,9 +4826,9 @@ integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== "@types/estree@^1.0.6": - version "1.0.7" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" - integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.33": version "4.19.3" @@ -4283,6 +4962,14 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== +"@types/node-fetch@^2.6.4": + version "2.6.12" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.12.tgz#8ab5c3ef8330f13100a7479e2cd56d3386830a03" + integrity sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA== + dependencies: + "@types/node" "*" + form-data "^4.0.0" + "@types/node-forge@^1.3.0": version "1.3.11" resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.11.tgz#0972ea538ddb0f4d9c2fa0ec5db5724773a604da" @@ -4302,6 +4989,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.45.tgz#2c0fafd78705e7a18b7906b5201a522719dc5190" integrity sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw== +"@types/node@^18.11.18": + version "18.19.86" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.86.tgz#a7e1785289c343155578b9d84a0e3e924deb948b" + integrity sha512-fifKayi175wLyKyc5qUfyENhQ1dCNI1UNjp653d8kuYcPQN5JhX3dGuP/XmvPTg/xRBn1VTLpbmi+H/Mr7tLfQ== + dependencies: + undici-types "~5.26.4" + "@types/parse5@^6.0.0": version "6.0.3" resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.3.tgz#705bb349e789efa06f43f128cef51240753424cb" @@ -4317,6 +5011,11 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== +"@types/prop-types@^15.7.14": + version "15.7.14" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.14.tgz#1433419d73b2a7ebfc6918dcefd2ec0d5cd698f2" + integrity sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ== + "@types/qs@*": version "6.9.15" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.15.tgz#adde8a060ec9c305a82de1babc1056e73bd64dce" @@ -4712,6 +5411,87 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== +"@zag-js/core@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/core/-/core-1.17.1.tgz#1d47e8117352cb42b3de0dd2672189a8e0bf955b" + integrity sha512-68jh6R87QLMYrtntu34eSF9JJXRXd+/l5Mpaz/InEOwA9sjxuyJIESqO578IpI2GAqk+cE1sUTKhhPmkzeTq3g== + dependencies: + "@zag-js/dom-query" "1.17.1" + "@zag-js/utils" "1.17.1" + +"@zag-js/dom-query@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@zag-js/dom-query/-/dom-query-1.10.0.tgz#62d5cdb887297c7522bde3e86ddb67cedf1cfad2" + integrity sha512-UQM4pHPPwpPNyuIcaDvuTjI4ntvBCV0oatpd+OcOW8NdUc2VVcPzL4cN6q1h+Q9s0Rpi+q77X0x6t9c1QWj1Iw== + dependencies: + "@zag-js/types" "1.10.0" + +"@zag-js/dom-query@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/dom-query/-/dom-query-1.17.1.tgz#38a8496869fb4fd1e02b6734d5f59e52d17bfc71" + integrity sha512-fwwzEKLPq3kAZVkkPBdskL4Ge4aHRAGqBLfAHCKioQNgvKYGRTzqmGA6ijls9ESULUWf0M2ogKstuUtY19PopA== + dependencies: + "@zag-js/types" "1.17.1" + +"@zag-js/focus-trap@^1.7.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@zag-js/focus-trap/-/focus-trap-1.10.0.tgz#c292010997ce09581aeb1729f9151a80aa4cf141" + integrity sha512-6+SPzXws7BurUb5AxHD6RoygInvPkGhleJmClQadeFhOlOdZdaeqwZjnoA3WoH/15V4NfUnoIzy72Su36D8RmA== + dependencies: + "@zag-js/dom-query" "1.10.0" + +"@zag-js/presence@^1.13.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/presence/-/presence-1.17.1.tgz#9fee698db453fa49c743a175910b2ba107a9bed5" + integrity sha512-2b9/4gs/ZuTpplqNjTARWjEgqkV8pMjcrH5u/fFng2cm5JRhcPrgWDSeOiahKOCdWj8x+f5EkNVvBOqs4Bmcsw== + dependencies: + "@zag-js/core" "1.17.1" + "@zag-js/dom-query" "1.17.1" + "@zag-js/types" "1.17.1" + +"@zag-js/react@^1.13.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/react/-/react-1.17.1.tgz#917f6fd739a9e54e73578f03813e2de96cc919c2" + integrity sha512-hgIpkHpfJByWMtaBvrJQNxBsEghFDWDWRx/JcG5cv+0VDS3bdT2U6b4AWRq6/6CMI1a2bXodgxXrgXj0t1UofQ== + dependencies: + "@zag-js/core" "1.17.1" + "@zag-js/store" "1.17.1" + "@zag-js/types" "1.17.1" + "@zag-js/utils" "1.17.1" + +"@zag-js/store@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/store/-/store-1.17.1.tgz#d7833045c56169f028324d64b8fd3dc2f78df22a" + integrity sha512-01iHhN08QezWTgouaAQdOW/WQUieTBv3Abl3QeGPtQ1UC8oygG84zea1uF+FzqxhT/KtWvI2AT0zRaw368aqVQ== + dependencies: + proxy-compare "3.0.1" + +"@zag-js/types@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@zag-js/types/-/types-1.10.0.tgz#d6f0d406d06cc954622b0234d2c2aeab64999ffd" + integrity sha512-HlM+EHYPLPaHgmuf2Bg5isNy2Kv30nwaANbkcMhVQYi8OfrTraxUQbTDXk3hb56qFmW1HQCMZzt1L7aS2qlOyQ== + dependencies: + csstype "3.1.3" + +"@zag-js/types@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/types/-/types-1.17.1.tgz#ce75409a9a89431f790038fd145cc9353d5fa236" + integrity sha512-KEPko1DK19hEMfM5IPKTZQtpf4HC3X56qwckezRX1yk+/vGotVUxdjRIrv+pcITjlFAoQQO9TiiZv2UiiVrFGA== + dependencies: + csstype "3.1.3" + +"@zag-js/utils@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/utils/-/utils-1.17.1.tgz#0015f9a160877672a75a2ed0419c289fb5fcb22d" + integrity sha512-+w/Kx7uZufg3cD6I5bQ8iSoeY3qSarPpUwrxz6FCOxJ86IAmf3ActqFC2pJ6DQCdHdkWINaKKchb4GNt8ld7KQ== + +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -4743,9 +5523,9 @@ acorn@^8.0.0, acorn@^8.0.4, acorn@^8.11.0, acorn@^8.7.1, acorn@^8.8.2: integrity sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw== acorn@^8.14.0: - version "8.14.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb" - integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== address@^1.0.1: version "1.2.2" @@ -4759,6 +5539,13 @@ agent-base@6: dependencies: debug "4" +agentkeepalive@^4.2.1: + version "4.6.0" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz#35f73e94b3f40bf65f105219c623ad19c136ea6a" + integrity sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== + dependencies: + humanize-ms "^1.2.1" + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -4822,30 +5609,30 @@ ajv@^8.0.0, ajv@^8.9.0: uri-js "^4.4.1" algoliasearch-helper@^3.22.6: - version "3.25.0" - resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.25.0.tgz#15cc79ad7909db66b8bb5a5a9c38b40e3941fa2f" - integrity sha512-vQoK43U6HXA9/euCqLjvyNdM4G2Fiu/VFp4ae0Gau9sZeIKBPvUPnXfLYAe65Bg7PFuw03coeu5K6lTPSXRObw== + version "3.26.0" + resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.26.0.tgz#d6e283396a9fc5bf944f365dc3b712570314363f" + integrity sha512-Rv2x3GXleQ3ygwhkhJubhhYGsICmShLAiqtUuJTUkr9uOCOXyF2E71LVT4XDnVffbknv8XgScP4U0Oxtgm+hIw== dependencies: "@algolia/events" "^4.0.1" algoliasearch@^5.14.2, algoliasearch@^5.17.1: - version "5.25.0" - resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-5.25.0.tgz#7337b097deadeca0e6e985c0f8724abea189994f" - integrity sha512-n73BVorL4HIwKlfJKb4SEzAYkR3Buwfwbh+MYxg2mloFph2fFGV58E90QTzdbfzWrLn4HE5Czx/WTjI8fcHaMg== + version "5.29.0" + resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-5.29.0.tgz#0feae8e0a71fced857be4e97c434ef9dce89783b" + integrity sha512-E2l6AlTWGznM2e7vEE6T6hzObvEyXukxMOlBmVlMyixZyK1umuO/CiVc6sDBbzVH0oEviCE5IfVY1oZBmccYPQ== dependencies: - "@algolia/client-abtesting" "5.25.0" - "@algolia/client-analytics" "5.25.0" - "@algolia/client-common" "5.25.0" - "@algolia/client-insights" "5.25.0" - "@algolia/client-personalization" "5.25.0" - "@algolia/client-query-suggestions" "5.25.0" - "@algolia/client-search" "5.25.0" - "@algolia/ingestion" "1.25.0" - "@algolia/monitoring" "1.25.0" - "@algolia/recommend" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-abtesting" "5.29.0" + "@algolia/client-analytics" "5.29.0" + "@algolia/client-common" "5.29.0" + "@algolia/client-insights" "5.29.0" + "@algolia/client-personalization" "5.29.0" + "@algolia/client-query-suggestions" "5.29.0" + "@algolia/client-search" "5.29.0" + "@algolia/ingestion" "1.29.0" + "@algolia/monitoring" "1.29.0" + "@algolia/recommend" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" allof-merge@^0.6.6: version "0.6.6" @@ -4854,6 +5641,11 @@ allof-merge@^0.6.6: dependencies: json-crawl "^0.5.3" +altcha-lib@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/altcha-lib/-/altcha-lib-1.2.0.tgz#a8b874ace261751473686adc5cc210be7449ba0d" + integrity sha512-S5WF8QLNRaM1hvK24XPhOLfu9is2EBCvH7+nv50sM5CaIdUCqQCd0WV/qm/ZZFGTdSoKLuDp+IapZxBLvC+SNg== + ansi-align@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" @@ -4932,6 +5724,13 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +aria-hidden@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.4.tgz#b78e383fdbc04d05762c78b4a25a501e736c4522" + integrity sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A== + dependencies: + tslib "^2.0.0" + array-flatten@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" @@ -4957,6 +5756,11 @@ async@3.2.4: resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + at-least-node@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" @@ -5312,10 +6116,10 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001599, caniuse-lite@^1.0.30001629: resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz" integrity sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA== -caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001716, caniuse-lite@^1.0.30001718: - version "1.0.30001718" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz#dae13a9c80d517c30c6197515a96131c194d8f82" - integrity sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw== +caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001718: + version "1.0.30001724" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001724.tgz#312e163553dd70d2c0fb603d74810c85d8ed94a0" + integrity sha512-WqJo7p0TbHDOythNTqYujmaJTvtYRZrjpP8TCvH6Vb9CYJerJNKamKzIWOM4BkQatWj9H2lYulpdAQNBe7QhNA== ccount@^2.0.0: version "2.0.1" @@ -5455,6 +6259,13 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== +class-variance-authority@0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz#4008a798a0e4553a781a57ac5177c9fb5d043787" + integrity sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg== + dependencies: + clsx "^2.1.1" + clean-css@^5.2.2, clean-css@^5.3.3, clean-css@~5.3.2: version "5.3.3" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.3.tgz#b330653cd3bd6b75009cc25c714cae7b93351ccd" @@ -5504,16 +6315,16 @@ clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" +clsx@2.1.1, clsx@^2.0.0, clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + clsx@^1.1.1, clsx@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== -clsx@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" - integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== - collapse-white-space@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-2.1.0.tgz#640257174f9f42c740b40f3b55ee752924feefca" @@ -5558,11 +6369,23 @@ colorette@^2.0.10: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== +colorjs.io@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/colorjs.io/-/colorjs.io-0.5.2.tgz#63b20139b007591ebc3359932bef84628eb3fcef" + integrity sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw== + combine-promises@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/combine-promises/-/combine-promises-1.2.0.tgz#5f2e68451862acf85761ded4d9e2af7769c2ca6a" integrity sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + comma-separated-tokens@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" @@ -5698,7 +6521,7 @@ content-disposition@0.5.4: dependencies: safe-buffer "5.2.1" -content-type@~1.0.4, content-type@~1.0.5: +content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== @@ -5750,11 +6573,11 @@ core-js-compat@^3.31.0, core-js-compat@^3.36.1: browserslist "^4.23.0" core-js-compat@^3.40.0: - version "3.42.0" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.42.0.tgz#ce19c29706ee5806e26d3cb3c542d4cfc0ed51bb" - integrity sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ== + version "3.43.0" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.43.0.tgz#055587369c458795ef316f65e0aabb808fb15840" + integrity sha512-2GML2ZsCc5LR7hZYz4AXmjQw8zuy2T//2QntwdnpuYI7jteT6GVYJL7F6C2C57R7gSYrcqVW3lAALefdbhBLDA== dependencies: - browserslist "^4.24.4" + browserslist "^4.25.0" core-js-pure@^3.30.2: version "3.37.1" @@ -5919,9 +6742,9 @@ css-what@^6.0.1, css-what@^6.1.0: integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== cssdb@^8.3.0: - version "8.3.0" - resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-8.3.0.tgz#940becad497b8509ad822a28fb0cfe54c969ccfe" - integrity sha512-c7bmItIg38DgGjSwDPZOYF/2o0QU/sSgkWOMyl8votOfgFuyiFKWPesmCGEsrGLxEA9uL540cp8LdaGEjUGsZQ== + version "8.3.1" + resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-8.3.1.tgz#0ac96395b7092ffee14563e948cf43c2019b051e" + integrity sha512-XnDRQMXucLueX92yDe0LPKupXetWoFOgawr4O4X41l5TltgK2NVbJJVDnnOywDYfW1sTJ28AcXGKOqdRKwCcmQ== cssesc@^3.0.0: version "3.0.0" @@ -5997,7 +6820,7 @@ csso@^5.0.5: dependencies: css-tree "~2.2.0" -csstype@^3.0.2: +csstype@3.1.3, csstype@^3.0.2: version "3.1.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== @@ -6404,6 +7227,11 @@ delaunator@5: dependencies: robust-predicates "^3.0.2" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + depd@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -6414,7 +7242,7 @@ depd@~1.1.2: resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== -dequal@^2.0.0: +dequal@^2.0.0, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== @@ -6434,6 +7262,11 @@ detect-libc@^2.0.3: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.4.tgz#f04715b8ba815e53b4d8109655b6508a6865a7e8" integrity sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA== +detect-node-es@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" + integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== + detect-node@^2.0.4: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" @@ -6601,7 +7434,7 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -dompurify@^3.2.4: +dompurify@^3.2.5: version "3.2.6" resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.6.tgz#ca040a6ad2b88e2a92dc45f38c79f84a714a1cad" integrity sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ== @@ -6670,11 +7503,6 @@ electron-to-chromium@^1.4.796: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.803.tgz#cf55808a5ee12e2a2778bbe8cdc941ef87c2093b" integrity sha512-61H9mLzGOCLLVsnLiRzCbc63uldP0AniRYPV3hbGVtONA1pI7qSGILdbofR7A8TMbOypDocEAjH/e+9k1QIe3g== -electron-to-chromium@^1.5.149: - version "1.5.158" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.158.tgz#e5f01fc7fdf810d9d223e30593e0839c306276d4" - integrity sha512-9vcp2xHhkvraY6AHw2WMi+GDSLPX42qe2xjYaVoZqFRJiOcilVQFq9mZmpuHEQpzlgGDelKlV7ZiGcmMsc8WxQ== - electron-to-chromium@^1.5.160: version "1.5.172" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.172.tgz#fe1d99028d8d6321668d0f1fed61d99ac896259c" @@ -6726,9 +7554,9 @@ enhanced-resolve@^5.17.0: tapable "^2.2.0" enhanced-resolve@^5.17.1: - version "5.18.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz#728ab082f8b7b6836de51f1637aab5d3b9568faf" - integrity sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg== + version "5.18.2" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz#7903c5b32ffd4b2143eeb4b92472bd68effd5464" + integrity sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -6779,6 +7607,16 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: dependencies: es-errors "^1.3.0" +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + es6-promise@^3.2.1: version "3.3.1" resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" @@ -6955,6 +7793,11 @@ eval@^0.1.8: "@types/node" "*" require-like ">= 0.1.1" +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter3@^4.0.0, eventemitter3@^4.0.4: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" @@ -7023,9 +7866,9 @@ express@^4.17.3: vary "~1.1.2" exsolve@^1.0.1: - version "1.0.5" - resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.5.tgz#1f5b6b4fe82ad6b28a173ccb955a635d77859dcf" - integrity sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg== + version "1.0.7" + resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.7.tgz#3b74e4c7ca5c5f9a19c3626ca857309fa99f9e9e" + integrity sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw== extend-shallow@^2.0.1: version "2.0.1" @@ -7200,16 +8043,39 @@ foreground-child@^3.1.0: cross-spawn "^7.0.0" signal-exit "^4.0.1" +form-data-encoder@1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" + integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A== + form-data-encoder@^2.1.2: version "2.1.4" resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.1.4.tgz#261ea35d2a70d48d30ec7a9603130fa5515e9cd5" integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw== +form-data@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.2.tgz#35cabbdd30c3ce73deb2c42d3c8d3ed9ca51794c" + integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + mime-types "^2.1.12" + format@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" integrity sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww== +formdata-node@^4.3.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/formdata-node/-/formdata-node-4.4.1.tgz#23f6a5cb9cb55315912cbec4ff7b0f59bbd191e2" + integrity sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ== + dependencies: + node-domexception "1.0.0" + web-streams-polyfill "4.0.0-beta.3" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -7290,7 +8156,7 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: has-symbols "^1.0.3" hasown "^2.0.0" -get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: +get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== @@ -7306,6 +8172,11 @@ get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: hasown "^2.0.2" math-intrinsics "^1.1.0" +get-nonce@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" + integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== + get-own-enumerable-property-symbols@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" @@ -7523,6 +8394,13 @@ has-symbols@^1.1.0: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + has-yarn@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-3.0.0.tgz#c3c21e559730d1d3b57e28af1f30d06fac38147d" @@ -7821,6 +8699,11 @@ html-tags@^3.3.1: resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ== +html-url-attributes@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz#83b052cd5e437071b756cd74ae70f708870c2d87" + integrity sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ== + html-void-elements@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-2.0.1.tgz#29459b8b05c200b6c5ee98743c41b979d577549f" @@ -7949,6 +8832,18 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== +humanize-ms@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" + integrity sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ== + dependencies: + ms "^2.0.0" + +humps@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa" + integrity sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g== + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -7989,9 +8884,9 @@ immer@^9.0.21: integrity sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA== immutable@^5.0.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.2.tgz#e8169476414505e5a4fa650107b65e1227d16d4b" - integrity sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ== + version "5.1.3" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.3.tgz#e6486694c8b76c37c063cca92399fa64098634d4" + integrity sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg== import-fresh@^3.3.0: version "3.3.0" @@ -8244,6 +9139,11 @@ is-typedarray@^1.0.0: resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== +is-what@^4.1.8: + version "4.1.16" + resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.16.tgz#1ad860a19da8b4895ad5495da3182ce2acdd7a6f" + integrity sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A== + is-wsl@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" @@ -8715,6 +9615,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lucide-react@^0.503.0: + version "0.503.0" + resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.503.0.tgz#4ac55b262fa613f9497531c9df50ea0e883d2de2" + integrity sha512-HGGkdlPWQ0vTF8jJ5TdIqhQXZi6uh3LnNgfZ8MHiuxFfX3RZeA79r2MW2tHAZKlAVfoNE8esm3p+O6VkIvpj6w== + markdown-extensions@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/markdown-extensions/-/markdown-extensions-2.0.0.tgz#34bebc83e9938cae16e0e017e4a9814a8330d3c4" @@ -8732,7 +9637,7 @@ markdown-table@^3.0.0: resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.3.tgz#e6331d30e493127e031dd385488b5bd326e4a6bd" integrity sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw== -marked@^15.0.7: +marked@^15.0.7, marked@^15.0.9: version "15.0.12" resolved "https://registry.yarnpkg.com/marked/-/marked-15.0.12.tgz#30722c7346e12d0a2d0207ab9b0c4f0102d86c4e" integrity sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA== @@ -9194,6 +10099,13 @@ memoize-one@^5.1.1: resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== +merge-anything@5.1.7: + version "5.1.7" + resolved "https://registry.yarnpkg.com/merge-anything/-/merge-anything-5.1.7.tgz#94f364d2b0cf21ac76067b5120e429353b3525d7" + integrity sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ== + dependencies: + is-what "^4.1.8" + merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" @@ -9210,13 +10122,13 @@ merge2@^1.3.0, merge2@^1.4.1: integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== mermaid@>=11.6.0: - version "11.6.0" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.6.0.tgz#eee45cdc3087be561a19faf01745596d946bb575" - integrity sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg== + version "11.7.0" + resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.7.0.tgz#53f319147632db15e499c5ccb72b24b277a00bae" + integrity sha512-/1/5R0rt0Z1Ak0CuznAnCF3HtQgayRXUz6SguzOwN4L+DuCobz0UxnQ+ZdTSZ3AugKVVh78tiVmsHpHWV25TCw== dependencies: "@braintree/sanitize-url" "^7.0.4" "@iconify/utils" "^2.1.33" - "@mermaid-js/parser" "^0.4.0" + "@mermaid-js/parser" "^0.5.0" "@types/d3" "^7.4.3" cytoscape "^3.29.3" cytoscape-cose-bilkent "^4.1.0" @@ -9225,7 +10137,7 @@ mermaid@>=11.6.0: d3-sankey "^0.12.3" dagre-d3-es "7.0.11" dayjs "^1.11.13" - dompurify "^3.2.4" + dompurify "^3.2.5" katex "^0.16.9" khroma "^2.1.0" lodash-es "^4.17.21" @@ -10041,7 +10953,7 @@ mime-types@2.1.31: dependencies: mime-db "1.48.0" -mime-types@2.1.35, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: +mime-types@2.1.35, mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -10147,7 +11059,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3, ms@^2.1.3: +ms@2.1.3, ms@^2.0.0, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -10207,6 +11119,11 @@ node-addon-api@^7.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== +node-domexception@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + node-emoji@^2.1.0: version "2.1.3" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-2.1.3.tgz#93cfabb5cc7c3653aa52f29d6ffb7927d8047c06" @@ -10231,7 +11148,7 @@ node-fetch@2.6.7: dependencies: whatwg-url "^5.0.0" -node-fetch@^2.6.1: +node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -10435,6 +11352,19 @@ open@^8.0.9, open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" +openai@4.78.1: + version "4.78.1" + resolved "https://registry.yarnpkg.com/openai/-/openai-4.78.1.tgz#44c3b195d239891be9c9c53722539ad8a1fcc5f2" + integrity sha512-drt0lHZBd2lMyORckOXFPQTmnGLWSLt8VK0W9BhOKWpMFBEoHMoz5gxMPmVq5icp+sOrsbMnsmZTVHUlKvD1Ow== + dependencies: + "@types/node" "^18.11.18" + "@types/node-fetch" "^2.6.4" + abort-controller "^3.0.0" + agentkeepalive "^4.2.1" + form-data-encoder "1.7.2" + formdata-node "^4.3.2" + node-fetch "^2.6.7" + openapi-to-postmanv2@^4.21.0: version "4.21.0" resolved "https://registry.yarnpkg.com/openapi-to-postmanv2/-/openapi-to-postmanv2-4.21.0.tgz#4bc5b19ccbd1514c2b3466268a7f5dd64b61f535" @@ -10690,7 +11620,7 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -path@0.12.7: +path@0.12.7, path@^0.12.7: version "0.12.7" resolved "https://registry.yarnpkg.com/path/-/path-0.12.7.tgz#d4dc2a506c4ce2197eb481ebfcd5b36c0140b10f" integrity sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q== @@ -11471,6 +12401,14 @@ pretty-time@^1.1.0: resolved "https://registry.yarnpkg.com/pretty-time/-/pretty-time-1.1.0.tgz#ffb7429afabb8535c346a34e41873adf3d74dd0e" integrity sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA== +prism-react-renderer@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz#ac63b7f78e56c8f2b5e76e823a976d5ede77e35f" + integrity sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig== + dependencies: + "@types/prismjs" "^1.26.0" + clsx "^2.0.0" + prism-react-renderer@^2.0.6, prism-react-renderer@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-2.3.1.tgz#e59e5450052ede17488f6bc85de1553f584ff8d5" @@ -11534,6 +12472,11 @@ proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +proxy-compare@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/proxy-compare/-/proxy-compare-3.0.1.tgz#3262cff3a25a6dedeaa299f6cf2369d6f7588a94" + integrity sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q== + proxy-from-env@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" @@ -11680,6 +12623,13 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.2" +react-error-boundary@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-6.0.0.tgz#a9e552146958fa77d873b587aa6a5e97544ee954" + integrity sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA== + dependencies: + "@babel/runtime" "^7.12.5" + react-fast-compare@^3.0.1, react-fast-compare@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" @@ -11701,6 +12651,11 @@ react-helmet-async@^1.3.0, "react-helmet-async@npm:@slorber/react-helmet-async@1 react-fast-compare "^3.2.0" shallowequal "^1.1.0" +react-hook-form@7.54.2: + version "7.54.2" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.54.2.tgz#8c26ed54c71628dff57ccd3c074b1dd377cfb211" + integrity sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg== + react-hook-form@^7.43.8: version "7.52.0" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.52.0.tgz#e52b33043e283719586b9dd80f6d51b68dd3999c" @@ -11759,6 +12714,22 @@ react-magic-dropzone@^1.0.1: resolved "https://registry.yarnpkg.com/react-magic-dropzone/-/react-magic-dropzone-1.0.1.tgz#bfd25b77b57e7a04aaef0a28910563b707ee54df" integrity sha512-0BIROPARmXHpk4AS3eWBOsewxoM5ndk2psYP/JmbCq8tz3uR2LIV1XiroZ9PKrmDRMctpW+TvsBCtWasuS8vFA== +react-markdown@9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-9.0.3.tgz#c12bf60dad05e9bf650b86bcc612d80636e8456e" + integrity sha512-Yk7Z94dbgYTOrdk41Z74GoKA7rThnsbbqBTRYuxoe08qvfQ9tJVhmAKw6BJS/ZORG7kTy/s1QvYzSuaoBA1qfw== + dependencies: + "@types/hast" "^3.0.0" + devlop "^1.0.0" + hast-util-to-jsx-runtime "^2.0.0" + html-url-attributes "^3.0.0" + mdast-util-to-hast "^13.0.0" + remark-parse "^11.0.0" + remark-rehype "^11.0.0" + unified "^11.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + react-markdown@^8.0.1: version "8.0.7" resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-8.0.7.tgz#c8dbd1b9ba5f1c5e7e5f2a44de465a3caafdf89b" @@ -11813,6 +12784,36 @@ react-redux@^7.2.0: prop-types "^15.7.2" react-is "^17.0.2" +react-remove-scroll-bar@^2.3.7: + version "2.3.8" + resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz#99c20f908ee467b385b68a3469b4a3e750012223" + integrity sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q== + dependencies: + react-style-singleton "^2.2.2" + tslib "^2.0.0" + +react-remove-scroll@^2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz#df02cde56d5f2731e058531f8ffd7f9adec91ac2" + integrity sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ== + dependencies: + react-remove-scroll-bar "^2.3.7" + react-style-singleton "^2.2.3" + tslib "^2.1.0" + use-callback-ref "^1.3.3" + use-sidecar "^1.1.3" + +react-remove-scroll@^2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz#d2101d414f6d81d7d3bf033f3c1cb4785789f753" + integrity sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA== + dependencies: + react-remove-scroll-bar "^2.3.7" + react-style-singleton "^2.2.3" + tslib "^2.1.0" + use-callback-ref "^1.3.3" + use-sidecar "^1.1.3" + react-router-config@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988" @@ -11848,6 +12849,33 @@ react-router@5.3.4, react-router@^5.3.4: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388" + integrity sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ== + dependencies: + get-nonce "^1.0.0" + tslib "^2.0.0" + +react-svg@16.3.0: + version "16.3.0" + resolved "https://registry.yarnpkg.com/react-svg/-/react-svg-16.3.0.tgz#de7a4bb6ee2d465c1ff7125ec27414ac27e907d7" + integrity sha512-MvoQbITgkmpPJYwDTNdiUyoncJFfoa0D86WzoZuMQ9c/ORJURPR6rPMnXDsLOWDCAyXuV9nKZhQhGyP0HZ0MVQ== + dependencies: + "@babel/runtime" "^7.26.0" + "@tanem/svg-injector" "^10.1.68" + "@types/prop-types" "^15.7.14" + prop-types "^15.8.1" + +react-textarea-autosize@8.5.7: + version "8.5.7" + resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.7.tgz#b2bf1913383a05ffef7fbc89c2ea21ba8133b023" + integrity sha512-2MqJ3p0Jh69yt9ktFIaZmORHXw4c4bxSIhCeWiFwmJ9EYKgLmuNII3e9c9b2UO+ijl4StnpZdqpxNIhTdHvqtQ== + dependencies: + "@babel/runtime" "^7.20.13" + use-composed-ref "^1.3.0" + use-latest "^1.2.1" + react@^18.2.0: version "18.3.1" resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" @@ -12008,6 +13036,15 @@ regjsparser@^0.9.1: dependencies: jsesc "~0.5.0" +rehype-raw@7.0.0, rehype-raw@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/rehype-raw/-/rehype-raw-7.0.0.tgz#59d7348fd5dbef3807bbaa1d443efd2dd85ecee4" + integrity sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww== + dependencies: + "@types/hast" "^3.0.0" + hast-util-raw "^9.0.0" + vfile "^6.0.0" + rehype-raw@^6.1.1: version "6.1.1" resolved "https://registry.yarnpkg.com/rehype-raw/-/rehype-raw-6.1.1.tgz#81bbef3793bd7abacc6bf8335879d1b6c868c9d4" @@ -12017,15 +13054,6 @@ rehype-raw@^6.1.1: hast-util-raw "^7.2.0" unified "^10.0.0" -rehype-raw@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/rehype-raw/-/rehype-raw-7.0.0.tgz#59d7348fd5dbef3807bbaa1d443efd2dd85ecee4" - integrity sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww== - dependencies: - "@types/hast" "^3.0.0" - hast-util-raw "^9.0.0" - vfile "^6.0.0" - relateurl@^0.2.7: version "0.2.7" resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" @@ -12084,6 +13112,18 @@ remark-gfm@^4.0.0: remark-stringify "^11.0.0" unified "^11.0.0" +remark-gfm@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.1.tgz#33227b2a74397670d357bf05c098eaf8513f0d6b" + integrity sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-gfm "^3.0.0" + micromark-extension-gfm "^3.0.0" + remark-parse "^11.0.0" + remark-stringify "^11.0.0" + unified "^11.0.0" + remark-mdx@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/remark-mdx/-/remark-mdx-3.0.1.tgz#8f73dd635c1874e44426e243f72c0977cf60e212" @@ -12304,9 +13344,9 @@ sass-loader@^16.0.2: neo-async "^2.6.2" sass@^1.80.4: - version "1.89.0" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.89.0.tgz#6df72360c5c3ec2a9833c49adafe57b28206752d" - integrity sha512-ld+kQU8YTdGNjOLfRWBzewJpU5cwEv/h5yyqlSeJcj6Yh8U4TDA9UA5FPicqDz/xgRPWRSYIQNiFks21TbA9KQ== + version "1.89.2" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.89.2.tgz#a771716aeae774e2b529f72c0ff2dfd46c9de10e" + integrity sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA== dependencies: chokidar "^4.0.0" immutable "^5.0.2" @@ -12994,6 +14034,11 @@ swc-loader@^0.2.6: dependencies: "@swc/counter" "^0.1.3" +tailwind-merge@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5" + integrity sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA== + tailwindcss@^3.2.4: version "3.4.4" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.4.tgz#351d932273e6abfa75ce7d226b5bf3a6cb257c05" @@ -13081,9 +14126,9 @@ terser@^5.10.0, terser@^5.15.1, terser@^5.26.0: source-map-support "~0.5.20" terser@^5.31.1: - version "5.40.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.40.0.tgz#839a80db42bfee8340085f44ea99b5cba36c55c8" - integrity sha512-cfeKl/jjwSR5ar7d0FGmave9hFGJT8obyo0z+CrQOylLDbk7X81nPU6vq9VORa5jU30SkDnT2FXjLbR8HLP+xA== + version "5.43.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.43.1.tgz#88387f4f9794ff1a29e7ad61fb2932e25b4fdb6d" + integrity sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg== dependencies: "@jridgewell/source-map" "^0.3.3" acorn "^8.14.0" @@ -13130,9 +14175,9 @@ tinyexec@^1.0.1: integrity sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw== tinypool@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.0.2.tgz#706193cc532f4c100f66aa00b01c42173d9051b2" - integrity sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA== + version "1.1.1" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" + integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== to-fast-properties@^2.0.0: version "2.0.0" @@ -13191,7 +14236,12 @@ ts-interface-checker@^0.1.9: resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== -tslib@^2.0.3, tslib@^2.6.0: +tslib@^2.0.0, tslib@^2.6.2: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +tslib@^2.0.3, tslib@^2.1.0, tslib@^2.6.0: version "2.6.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== @@ -13486,11 +14536,48 @@ url@^0.11.1: punycode "^1.4.1" qs "^6.12.3" +use-callback-ref@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz#98d9fab067075841c5b2c6852090d5d0feabe2bf" + integrity sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg== + dependencies: + tslib "^2.0.0" + +use-composed-ref@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.4.0.tgz#09e023bf798d005286ad85cd20674bdf5770653b" + integrity sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w== + use-editable@^2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/use-editable/-/use-editable-2.3.3.tgz#a292fe9ba4c291cd28d1cc2728c75a5fc8d9a33f" integrity sha512-7wVD2JbfAFJ3DK0vITvXBdpd9JAz5BcKAAolsnLBuBn6UDDwBGuCIAGvR3yA2BNKm578vAMVHFCWaOcA+BhhiA== +use-isomorphic-layout-effect@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz#afb292eb284c39219e8cb8d3d62d71999361a21d" + integrity sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w== + +use-latest@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.3.0.tgz#549b9b0d4c1761862072f0899c6f096eb379137a" + integrity sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ== + dependencies: + use-isomorphic-layout-effect "^1.1.1" + +use-sidecar@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb" + integrity sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ== + dependencies: + detect-node-es "^1.1.0" + tslib "^2.0.0" + +use-sync-external-store@^1.4.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0" + integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -13699,6 +14786,11 @@ web-namespaces@^2.0.0: resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== +web-streams-polyfill@4.0.0-beta.3: + version "4.0.0-beta.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" + integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" From 4cd52f33ebb8d3fb8a2f7e14bc9d8aeafbbdd976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Mon, 30 Jun 2025 08:48:04 +0300 Subject: [PATCH 12/66] chore(oidc): remove feature flag for introspection triggers (#10132) # Which Problems Are Solved Remove the feature flag that allowed triggers in introspection. This option was a fallback in case introspection would not function properly without triggers. The API documentation asked for anyone using this flag to raise an issue. No such issue was received, hence we concluded it is safe to remove it. # How the Problems Are Solved - Remove flags from the system and instance level feature APIs. - Remove trigger functions that are no longer used - Adjust tests that used the flag. # Additional Changes - none # Additional Context - Closes #10026 - Flag was introduced in #7356 --------- Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com> --- cmd/setup/config_test.go | 7 +- cmd/start/config_test.go | 7 +- .../components/features/features.component.ts | 1 - console/src/assets/i18n/bg.json | 2 - console/src/assets/i18n/cs.json | 2 - console/src/assets/i18n/de.json | 2 - console/src/assets/i18n/en.json | 2 - console/src/assets/i18n/es.json | 2 - console/src/assets/i18n/fr.json | 2 - console/src/assets/i18n/hu.json | 2 - console/src/assets/i18n/id.json | 2 - console/src/assets/i18n/it.json | 2 - console/src/assets/i18n/ja.json | 2 - console/src/assets/i18n/ko.json | 2 - console/src/assets/i18n/mk.json | 2 - console/src/assets/i18n/nl.json | 2 - console/src/assets/i18n/pl.json | 2 - console/src/assets/i18n/pt.json | 2 - console/src/assets/i18n/ro.json | 2 - console/src/assets/i18n/ru.json | 2 - console/src/assets/i18n/sv.json | 2 - console/src/assets/i18n/zh.json | 2 - internal/api/grpc/feature/v2/converter.go | 88 +++++++++---------- .../api/grpc/feature/v2/converter_test.go | 68 +++++--------- .../v2/integration_test/feature_test.go | 33 ++----- internal/api/grpc/feature/v2beta/converter.go | 52 +++++------ .../api/grpc/feature/v2beta/converter_test.go | 60 +++++-------- .../v2beta/integration_test/feature_test.go | 22 +---- internal/api/oidc/access_token.go | 3 +- .../oidc/integration_test/userinfo_test.go | 37 -------- internal/api/oidc/introspect.go | 6 -- internal/api/oidc/userinfo.go | 6 -- internal/command/instance_features.go | 24 +++-- internal/command/instance_features_model.go | 5 -- internal/command/instance_features_test.go | 42 ++------- internal/command/system_features.go | 20 ++--- internal/command/system_features_model.go | 5 -- internal/command/system_features_test.go | 44 ++-------- internal/feature/feature.go | 50 +++++------ internal/feature/feature_test.go | 1 - internal/feature/key_enumer.go | 14 ++- internal/query/instance_features.go | 25 +++--- internal/query/instance_features_model.go | 4 - internal/query/instance_features_test.go | 36 -------- internal/query/introspection.go | 6 -- .../query/projection/instance_features.go | 4 - internal/query/projection/system_features.go | 4 - internal/query/system_features.go | 19 ++-- internal/query/system_features_model.go | 3 - internal/query/system_features_test.go | 32 ------- internal/query/userinfo.go | 6 -- .../feature/feature_v2/eventstore.go | 2 - .../repository/feature/feature_v2/feature.go | 46 +++++----- proto/zitadel/feature/v2/instance.proto | 21 +---- proto/zitadel/feature/v2/system.proto | 22 +---- proto/zitadel/feature/v2beta/instance.proto | 21 +---- proto/zitadel/feature/v2beta/system.proto | 22 +---- 57 files changed, 247 insertions(+), 659 deletions(-) diff --git a/cmd/setup/config_test.go b/cmd/setup/config_test.go index b147ed54a7..6c087fe402 100644 --- a/cmd/setup/config_test.go +++ b/cmd/setup/config_test.go @@ -36,8 +36,6 @@ func TestMustNewConfig(t *testing.T) { DefaultInstance: Features: LoginDefaultOrg: true - LegacyIntrospection: true - TriggerIntrospectionProjections: true UserSchema: true Log: Level: info @@ -47,9 +45,8 @@ Actions: `}, want: func(t *testing.T, config *Config) { assert.Equal(t, config.DefaultInstance.Features, &command.InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(true), - UserSchema: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), }) }, }, { diff --git a/cmd/start/config_test.go b/cmd/start/config_test.go index 918fa51950..3c8328e557 100644 --- a/cmd/start/config_test.go +++ b/cmd/start/config_test.go @@ -73,8 +73,6 @@ Log: DefaultInstance: Features: LoginDefaultOrg: true - LegacyIntrospection: true - TriggerIntrospectionProjections: true UserSchema: true Log: Level: info @@ -84,9 +82,8 @@ Actions: `}, want: func(t *testing.T, config *Config) { assert.Equal(t, config.DefaultInstance.Features, &command.InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(true), - UserSchema: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), }) }, }, { diff --git a/console/src/app/components/features/features.component.ts b/console/src/app/components/features/features.component.ts index ace2788fcf..8e8c0f9106 100644 --- a/console/src/app/components/features/features.component.ts +++ b/console/src/app/components/features/features.component.ts @@ -35,7 +35,6 @@ const FEATURE_KEYS = [ 'loginDefaultOrg', 'oidcSingleV1SessionTermination', 'oidcTokenExchange', - 'oidcTriggerIntrospectionProjections', 'permissionCheckV2', 'userSchema', ] as const; diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index 50c0d66027..2d51fa2571 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1625,8 +1625,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Потребителският интерфейс за влизане ще използва настройките на организацията по подразбиране (а не на инстанцията), ако не е зададен контекст на организация.", "OIDCTOKENEXCHANGE": "Обмяна на токени OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Активиране на експерименталния тип на дарение urn:ietf:params:oauth:grant-type:token-exchange за краен пункт на токен OIDC. Обменът на токени може да се използва за заявка на токени с по-малък обхват или за имперсонализиране на други потребители. Вижте политиката за сигурност, за да разрешите имперсонализацията на инстанция.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Тригери за проекции на осмисляне на OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Активиране на тригери за проекции по време на заявка за осмисляне. Това може да действа като обходен механизъм, ако има забележими проблеми с консистентността в отговора на осмислянето, но може да окаже влияние върху производителността. Планираме да премахнем тригерите за заявки за осмисляне в бъдеще.", "USERSCHEMA": "Потребителска схема", "USERSCHEMA_DESCRIPTION": "Потребителските схеми позволяват управление на данните за схемите на потребителите. Ако е активиран флагът, ще можете да използвате новото API и неговите функции.", "ACTIONS": "Действия", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 5b4547ccb4..cfa342f548 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Přihlašovací rozhraní použije nastavení výchozí organizace (a ne z instance), pokud není nastaven žádný kontext organizace.", "OIDCTOKENEXCHANGE": "Výměna tokenů OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Povolit experimentální typ udělení urn:ietf:params:oauth:grant-type:token-exchange pro bod tokenového bodu OIDC. Výměna tokenů lze použít k žádosti o tokeny s menším rozsahem nebo k impersonaci jiných uživatelů. Podívejte se na bezpečnostní politiku, abyste umožnili impersonaci na instanci.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Spouštěče projekcí introspekce OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Povolit spouštěče projekcí během požadavku na introspekci. To může sloužit jako obcházení, pokud existují zjevné problémy s konzistencí v odpovědi na introspekci, ale může to mít vliv na výkon. Plánujeme odstranit spouštěče pro požadavky na introspekci v budoucnosti.", "USERSCHEMA": "Schéma uživatele", "USERSCHEMA_DESCRIPTION": "Schémata uživatelů umožňují spravovat datová schémata uživatelů. Pokud je příznak povolen, budete moci používat nové API a jeho funkce.", "ACTIONS": "Akce", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index 8fec6498ec..ad278c5863 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Die Anmelde-Benutzeroberfläche verwendet die Einstellungen der Standardorganisation (und nicht von der Instanz), wenn kein Organisationskontext festgelegt ist.", "OIDCTOKENEXCHANGE": "OIDC Token-Austausch", "OIDCTOKENEXCHANGE_DESCRIPTION": "Aktivieren Sie den experimentellen urn:ietf:params:oauth:grant-type:token-exchange-Grant-Typ für den OIDC-Token-Endpunkt. Der Token-Austausch kann verwendet werden, um Token mit einem geringeren Umfang anzufordern oder andere Benutzer zu impersonieren. Siehe die Sicherheitsrichtlinie, um die Impersonation auf einer Instanz zu erlauben.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Trigger-Introspektionsprojektionen", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Aktivieren Sie Projektionstrigger während einer Introspektionsanfrage. Dies kann als Workaround fungieren, wenn bemerkbare Konsistenzprobleme in der Introspektionsantwort auftreten, kann sich jedoch auf die Leistung auswirken. Wir planen, Trigger für Introspektionsanfragen in Zukunft zu entfernen.", "USERSCHEMA": "Benutzerschema", "USERSCHEMA_DESCRIPTION": "Benutzerschemata ermöglichen das Verwalten von Datenschemata von Benutzern. Wenn die Flagge aktiviert ist, können Sie die neue API und ihre Funktionen verwenden.", "ACTIONS": "Aktionen", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 95fd55bfef..6e584f9336 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1629,8 +1629,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "The login UI will use the settings of the default org (and not from the instance) if no organization context is set", "OIDCTOKENEXCHANGE": "OIDC Token Exchange", "OIDCTOKENEXCHANGE_DESCRIPTION": "Enable the experimental urn:ietf:params:oauth:grant-type:token-exchange grant type for the OIDC token endpoint. Token exchange can be used to request tokens with a lesser scope or impersonate other users. See the security policy to allow impersonation on an instance.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Trigger introspection Projections", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future.", "USERSCHEMA": "User Schema", "USERSCHEMA_DESCRIPTION": "User Schemas allow to manage data schemas of user. If the flag is enabled, you'll be able to use the new API and its features.", "ACTIONS": "Actions", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 359aa4a0b5..55007b3086 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1627,8 +1627,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "La interfaz de inicio de sesión utilizará la configuración de la organización predeterminada (y no de la instancia) si no se establece ningún contexto de organización.", "OIDCTOKENEXCHANGE": "Intercambio de tokens OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Habilita el tipo de concesión experimental urn:ietf:params:oauth:grant-type:token-exchange para el punto de extremo de token OIDC. El intercambio de tokens se puede utilizar para solicitar tokens con un alcance menor o suplantar a otros usuarios. Consulta la política de seguridad para permitir la suplantación en una instancia.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Desencadenadores de proyecciones de introspección OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Habilita los desencadenadores de proyección durante una solicitud de introspección. Esto puede actuar como un mecanismo alternativo si hay problemas de coherencia perceptibles en la respuesta a la introspección, pero puede afectar al rendimiento. Estamos planeando eliminar los desencadenadores para las solicitudes de introspección en el futuro.", "USERSCHEMA": "Esquema de usuario", "USERSCHEMA_DESCRIPTION": "Los esquemas de usuario permiten gestionar los esquemas de datos de los usuarios. Si se activa la bandera, podrás utilizar la nueva API y sus funciones.", "ACTIONS": "Acciones", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 0864a2f8c0..cda6a044ff 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "L'interface de connexion utilisera les paramètres de l'organisation par défaut (et non de l'instance) si aucun contexte d'organisation n'est défini.", "OIDCTOKENEXCHANGE": "Échange de jetons OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Activez le type d'octroi expérimental urn:ietf:params:oauth:grant-type:token-exchange pour le point de terminaison de jeton OIDC. L'échange de jetons peut être utilisé pour demander des jetons avec une portée moindre ou pour usurper d'autres utilisateurs. Consultez la politique de sécurité pour autoriser l'usurpation sur une instance.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Déclencheurs de projections d'introspection OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Activez les déclencheurs de projection lors d'une demande d'introspection. Cela peut agir comme un contournement s'il existe des problèmes de cohérence perceptibles dans la réponse à l'introspection, mais cela peut avoir un impact sur les performances. Nous prévoyons de supprimer les déclencheurs pour les demandes d'introspection à l'avenir.", "USERSCHEMA": "Schéma utilisateur", "USERSCHEMA_DESCRIPTION": "Les schémas utilisateur permettent de gérer les schémas de données des utilisateurs. Si le drapeau est activé, vous pourrez utiliser la nouvelle API et ses fonctionnalités.", "ACTIONS": "Actions", diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index a87122dc52..133d183355 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -1624,8 +1624,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "A bejelentkezési felület az alapértelmezett org beállításait fogja használni (és nem az instance-tól), ha nincs megadva szervezeti kontextus", "OIDCTOKENEXCHANGE": "OIDC Token Exchange", "OIDCTOKENEXCHANGE_DESCRIPTION": "Engedélyezd a kísérleti urn:ietf:params:oauth:grant-type:token-exchange támogatását az OIDC token végpont számára. A token csere használható kisebb hatókörű tokenek kérésére vagy más felhasználók megszemélyesítésére. Tekintsd meg a biztonsági irányelvet az impersonáció engedélyezéséhez egy példányon.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Introspekciós Projekciók Indítása", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Engedélyezd a projekciós indítókat az introspekciós kérés során. Ez lehet egy megoldás, ha észrevehető konzisztenciaproblémák vannak az introspekciós válaszban, de hatással lehet a teljesítményre. Tervezzük, hogy a jövőben eltávolítjuk a triggereket az introspekciós kérésből.", "USERSCHEMA": "Felhasználói Séma", "USERSCHEMA_DESCRIPTION": "A Felhasználói Sémák lehetővé teszik a felhasználói adat sémák kezelését. Ha az opció engedélyezve van, használhatod az új API-t és annak funkcióit.", "ACTIONS": "Műveletek", diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index 3f245d03c5..e494beeeca 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -1500,8 +1500,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "UI login akan menggunakan pengaturan organisasi default (dan bukan dari instance) jika tidak ada konteks organisasi yang ditetapkan", "OIDCTOKENEXCHANGE": "Pertukaran Token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Aktifkan jenis pemberian urn:ietf:params:oauth:grant-type:token-exchange eksperimental untuk titik akhir token OIDC. Pertukaran token dapat digunakan untuk meminta token dengan cakupan yang lebih kecil atau menyamar sebagai pengguna lain. Lihat kebijakan keamanan untuk mengizinkan peniruan identitas pada sebuah instans.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Memicu Proyeksi Introspeksi", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Aktifkan pemicu proyeksi selama permintaan introspeksi. Hal ini dapat menjadi solusi jika terdapat masalah konsistensi yang nyata dalam respons introspeksi namun dapat berdampak pada kinerja. Kami berencana untuk menghilangkan pemicu permintaan introspeksi di masa depan.", "USERSCHEMA": "Skema Pengguna", "USERSCHEMA_DESCRIPTION": "Skema Pengguna memungkinkan untuk mengelola skema data pengguna. Jika tanda ini diaktifkan, Anda akan dapat menggunakan API baru dan fitur-fiturnya.", "ACTIONS": "Tindakan", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index e127281433..fa009cac77 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "L'interfaccia di accesso utilizzerà le impostazioni dell'organizzazione predefinita (e non dell'istanza) se non è impostato alcun contesto organizzativo.", "OIDCTOKENEXCHANGE": "Scambio token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Abilita il tipo di concessione sperimentale urn:ietf:params:oauth:grant-type:token-exchange per il punto finale del token OIDC. Lo scambio di token può essere utilizzato per richiedere token con uno scopo inferiore o impersonare altri utenti. Consultare la policy di sicurezza per consentire l'impersonificazione su un'istanza.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Proiezioni trigger OIDC per l'introspezione", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Abilita i trigger di proiezione durante una richiesta di introspezione. Questo può agire come soluzione alternativa se ci sono problemi di coerenza evidenti nella risposta all'introspezione, ma può influire sulle prestazioni. Stiamo pianificando di rimuovere i trigger per le richieste di introspezione in futuro.", "USERSCHEMA": "Schema utente", "USERSCHEMA_DESCRIPTION": "Gli schemi utente consentono di gestire gli schemi di dati degli utenti. Se la flag è attivata, sarà possibile utilizzare la nuova API e le sue funzionalità.", "ACTIONS": "Azioni", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 250561e938..dc5dfd4a34 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "組織コンテキストが設定されていない場合、ログイン UI は既定の組織の設定を使用します (インスタンスの設定ではなく)", "OIDCTOKENEXCHANGE": "OIDC トークン交換", "OIDCTOKENEXCHANGE_DESCRIPTION": "OIDC トークン エンドポイント用に実験的な urn:ietf:params:oauth:grant-type:token-exchange 付与タイプを有効にします。トークン交換は、より少ないスコープを持つトークンを要求するか、他のユーザーになりすますために使用できます。インスタンスでのなりすましを許可するには、セキュリティポリシーを参照してください。", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC トリガーイントロスペクションプロジェクション", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "イントロスペクション要求中にプロジェクショントリガーを有効にします。これは、イントロスペクションレスポンスに顕著な整合性問題がある場合の回避策として機能しますが、パフォーマンスに影響を与える可能性があります。今後、イントロスペクション要求のトリガーを削除する予定です。", "USERSCHEMA": "ユーザー スキーマ", "USERSCHEMA_DESCRIPTION": "ユーザー スキーマを使用すると、ユーザーのデータスキーマを管理できます。フラグが有効になっている場合、新しい APIとその機能を使用できます。", "ACTIONS": "アクション", diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index 716375941d..eaf9968a66 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "조직 컨텍스트가 설정되지 않은 경우 로그인 UI가 기본 조직의 설정을 사용합니다 (인스턴스에서 설정되지 않음).", "OIDCTOKENEXCHANGE": "OIDC 토큰 교환", "OIDCTOKENEXCHANGE_DESCRIPTION": "OIDC 토큰 엔드포인트의 실험적 urn:ietf:params:oauth:grant-type:token-exchange 허용을 활성화합니다. 토큰 교환을 통해 범위가 좁은 토큰을 요청하거나 다른 사용자를 가장할 수 있습니다. 인스턴스에서 가장을 허용하는 보안 정책을 확인하세요.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC 트리거 내부 조사 프로젝션", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "내부 조사 요청 중 프로젝션 트리거를 활성화합니다. 이는 내부 조사 응답에서 일관성 문제가 있는 경우 임시 해결책으로 작동할 수 있으나 성능에 영향을 미칠 수 있습니다. 향후 내부 조사 요청에 대한 트리거 제거를 계획 중입니다.", "USERSCHEMA": "사용자 스키마", "USERSCHEMA_DESCRIPTION": "사용자 스키마를 통해 사용자의 데이터 스키마를 관리할 수 있습니다. 플래그가 활성화되면 새 API 및 기능을 사용할 수 있습니다.", "ACTIONS": "액션", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index 39836f5dfc..543456df24 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1627,8 +1627,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Интерфејсот за најавување ќе ги користи поставките на стандардната организација (а не од примерот) ако не е поставен контекст на организацијата", "OIDCTOKENEXCHANGE": "Размена на токени OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Овозможете го експерименталниот тип на грант urn:ietf:params:oauth:grant-type:token-exchange за крајната точка на токенот OIDC. Размената на токени може да се користи за барање токени со помал опсег или имитирање на други корисници. Погледнете ја безбедносната политика за да дозволите имитирање на пример.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Проекции за интроспекција на активирањето на OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Овозможете предизвикувачи за проекција за време на барање за интроспекција. Ова може да дејствува како заобиколување ако има забележителни проблеми со конзистентноста во одговорот на интроспекцијата, но може да има влијание врз перформансите. Планираме да ги отстраниме предизвикувачите за барањата за интроспекција во иднина.", "USERSCHEMA": "Корисничка шема", "USERSCHEMA_DESCRIPTION": "Корисничките шеми овозможуваат управување со податоци шеми на корисникот. Ако знамето е овозможено, ќе можете да го користите новиот API и неговите функции.", "ACTIONS": "Акции", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index c49867aa3e..f8e81a1310 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Als er geen organisatiecontext is ingesteld, gebruikt de inlog-UI de instellingen van de standaardorganisatie (en niet van de instantie)", "OIDCTOKENEXCHANGE": "OIDC-tokenuitwisseling", "OIDCTOKENEXCHANGE_DESCRIPTION": "Schakel het experimentele type verlening urn:ietf:params:oauth:grant-type:token-exchange in voor het OIDC-tokenendpoint. Tokenuitwisseling kan worden gebruikt om tokens met een kleinere scope op te vragen of om zich voor te doen als andere gebruikers. Raadpleeg het beveiligingsbeleid om impersonation op een instantie toe te staan.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC-triggers voor introspectieprojecties", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Schakel projectietriggers in tijdens een introspectieverzoek. Dit kan dienen als een tijdelijke oplossing als er merkbare consistentieproblemen optreden in het introspectieantwoord, maar het kan wel prestaties beïnvloeden. We zijn van plan om triggers voor introspectieverzoeken in de toekomst te verwijderen.", "USERSCHEMA": "Gebruikerschema", "USERSCHEMA_DESCRIPTION": "Met gebruikerschema's kunt u de dataschema's van gebruikers beheren. Als de vlag is ingeschakeld, kunt u de nieuwe API en zijn functies gebruiken.", "ACTIONS": "Acties", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index abf2e1ba8a..def9e920b6 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1625,8 +1625,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Jeśli nie ustawiono kontekstu organizacji, interfejs logowania będzie używać ustawień domyślnej organizacji (a nie instancji)", "OIDCTOKENEXCHANGE": "Wymiana Tokenów OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Włącz eksperymentalny typ grantu urn:ietf:params:oauth:grant-type:token-exchange dla punktu końcowego tokena OIDC. Wymiana tokenów może być używana do żądania tokenów o mniejszym zakresie lub podszywania się za innych użytkowników. Aby zezwolić na podszywanie się na instancji, zapoznaj się z polityką bezpieczeństwa.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Projekcje Introspekcji Wyzwalane przez OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Włącz wyzwalacze projekcji podczas żądania introspekcji. Może to stanowić obejście, jeśli w odpowiedzi introspekcji występują zauważalne problemy z spójnością, ale może mieć wpływ na wydajność. Planujemy w przyszłości usunąć wyzwalacze dla żądań introspekcji.", "USERSCHEMA": "Schemat Użytkownika", "USERSCHEMA_DESCRIPTION": "Schematy użytkowników umożliwiają zarządzanie schematami danych użytkowników. Jeśli flaga jest włączona, będziesz mógł korzystać z nowego interfejsu API i jego funkcji.", "ACTIONS": "Akcje", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 8b858bd44e..92363fff7b 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1627,8 +1627,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "A interface de login utilizará as configurações da organização padrão (e não da instância) se nenhum contexto de organização estiver definido", "OIDCTOKENEXCHANGE": "Troca de Token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Habilita o tipo de concessão experimental urn:ietf:params:oauth:grant-type:token-exchange para o endpoint de token OIDC. A troca de token pode ser usada para solicitar tokens com escopo menor ou personificar outros usuários. Consulte a política de segurança para permitir a personificação em uma instância.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Projeções de Introspecção com Gatilho OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Habilita gatilhos de projeção durante uma solicitação de introspecção. Isso pode funcionar como uma solução alternativa se houver problemas de consistência perceptíveis na resposta de introspecção, mas pode impactar o desempenho. Planejamos remover gatilhos para solicitações de introspecção no futuro.", "USERSCHEMA": "Esquema de Usuário", "USERSCHEMAS_DESCRIPTION": "Esquemas de Usuário permitem gerenciar esquemas de dados do usuário. Se o sinalizador estiver ativado, você poderá usar a nova API e seus recursos.", "ACTIONS": "Ações", diff --git a/console/src/assets/i18n/ro.json b/console/src/assets/i18n/ro.json index d2f51a81e0..a7bf9b4f23 100644 --- a/console/src/assets/i18n/ro.json +++ b/console/src/assets/i18n/ro.json @@ -1624,8 +1624,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "UI-ul de conectare va utiliza setările organizației implicite (și nu din instanță) dacă nu este setat niciun context de organizație", "OIDCTOKENEXCHANGE": "Schimb de token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Activați tipul de grant experimental urn:ietf:params:oauth:grant-type:token-exchange pentru endpointul token OIDC. Schimbul de tokenuri poate fi utilizat pentru a solicita tokenuri cu o rază de acțiune mai mică sau pentru a impersona alți utilizatori. Consultați politica de securitate pentru a permite impersonarea pe o instanță.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Proiecții de introspecție OIDC Trigger", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Activați declanșatoarele de proiecție în timpul unei solicitări de introspecție. Acest lucru poate acționa ca o soluție dacă există probleme notabile de consistență în răspunsul de introspecție, dar poate avea un impact asupra performanței. Planificăm să eliminăm declanșatoarele pentru solicitările de introspecție în viitor.", "USERSCHEMA": "Schema de utilizator", "USERSCHEMA_DESCRIPTION": "Schemele de utilizator permit gestionarea schemelor de date ale utilizatorului. Dacă indicatorul este activat, veți putea utiliza noul API și caracteristicile sale.", "ACTIONS": "Acțiuni", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 3070b311e7..40f35bdcc8 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -1679,8 +1679,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Если контекст организации не установлен, пользовательский интерфейс входа будет использовать настройки организации по умолчанию (а не экземпляра)", "OIDCTOKENEXCHANGE": "Обмен токенами OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Включите экспериментальный тип гранта urn:ietf:params:oauth:grant-type:token-exchange для конечной точки токена OIDC. Обмен токенами можно использовать для запроса токенов с меньшей областью действия или для impersonation (выдачи себя за) других пользователей. Информацию о разрешении impersonation на экземпляре см. в политике безопасности.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Проекции интроспекции с триггером OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Включите триггеры проекций во время запроса интроспекции. Это может служить обходным путем, если в ответе интроспекции наблюдаются заметные проблемы согласованности, но может повлиять на производительность. В будущем мы планируем удалить триггеры для запросов интроспекции.", "USERSCHEMA": "Схема пользователя", "USERSCHEMA_DESCRIPTION": "Схемы пользователей позволяют управлять схемами данных пользователей. Если флаг включен, вы сможете использовать новый API и его функции.", "ACTIONS": "Действия", diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 8f03501054..6dfe81f99c 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -1630,8 +1630,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Inloggningsgränssnittet kommer att använda inställningarna för standardorganisationen (och inte från instansen) om ingen organisationskontext är inställd", "OIDCTOKENEXCHANGE": "OIDC Token Exchange", "OIDCTOKENEXCHANGE_DESCRIPTION": "Aktivera den experimentella urn:ietf:params:oauth:grant-type:token-exchange grant-typen för OIDC-tokenändpunkten. Tokenutbyte kan användas för att begära tokens med en mindre omfattning eller impersonera andra användare. Se säkerhetspolicyn för att tillåta impersonation på en instans.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Trigger introspection Projections", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Aktivera projektionstriggers under en introspektionsbegäran. Detta kan fungera som en lösning om det finns märkbara konsistensproblem i introspektionssvaret men kan påverka prestandan. Vi planerar att ta bort triggers för introspektionsbegäranden i framtiden.", "USERSCHEMA": "Användarschema", "USERSCHEMA_DESCRIPTION": "Användarscheman tillåter att hantera datascheman för användare. Om flaggan är aktiverad kommer du att kunna använda det nya API:et och dess funktioner.", "ACTIONS": "Åtgärder", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 0431405979..c4b40d71ea 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "如果没有设置组织上下文,登录界面将使用默认组织的设置(而不是实例的设置)", "OIDCTOKENEXCHANGE": "OIDC 令牌交换", "OIDCTOKENEXCHANGE_DESCRIPTION": "启用 OIDC 令牌端点的实验性 urn:ietf:params:oauth:grant-type:token-exchange 授权类型。令牌交换可用于请求具有较少范围的令牌或模拟其他用户。请参阅安全策略以允许在实例上模拟。", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC 触发内省投影", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "在内省请求期间启用投影触发器。如果内省响应中存在明显的一致性问题,这可以作为一个解决方法,但可能会影响性能。我们计划在未来删除内省请求的触发器。", "USERSCHEMA": "用户架构", "USERSCHEMA_DESCRIPTION": "用户架构允许管理用户的数据架构。如果启用此标志,您将可以使用新的 API 及其功能。", "ACTIONS": "操作", diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index 1f0a3b21e7..ab8ddc7d75 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -18,32 +18,30 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) (*command return nil, err } return &command.SystemFeatures{ - LoginDefaultOrg: req.LoginDefaultOrg, - TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - UserSchema: req.UserSchema, - TokenExchange: req.OidcTokenExchange, - ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, - DisableUserTokenEvent: req.DisableUserTokenEvent, - EnableBackChannelLogout: req.EnableBackChannelLogout, - LoginV2: loginV2, - PermissionCheckV2: req.PermissionCheckV2, + LoginDefaultOrg: req.LoginDefaultOrg, + UserSchema: req.UserSchema, + TokenExchange: req.OidcTokenExchange, + ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, + DisableUserTokenEvent: req.DisableUserTokenEvent, + EnableBackChannelLogout: req.EnableBackChannelLogout, + LoginV2: loginV2, + PermissionCheckV2: req.PermissionCheckV2, }, nil } 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), - UserSchema: featureSourceToFlagPb(&f.UserSchema), - OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), - DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), - EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), - LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), - PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + UserSchema: featureSourceToFlagPb(&f.UserSchema), + OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), + ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), + DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), + EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), + LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), + PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), } } @@ -53,36 +51,34 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) (*com return nil, err } return &command.InstanceFeatures{ - LoginDefaultOrg: req.LoginDefaultOrg, - TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - UserSchema: req.UserSchema, - TokenExchange: req.OidcTokenExchange, - ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - DebugOIDCParentError: req.DebugOidcParentError, - OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, - DisableUserTokenEvent: req.DisableUserTokenEvent, - EnableBackChannelLogout: req.EnableBackChannelLogout, - LoginV2: loginV2, - PermissionCheckV2: req.PermissionCheckV2, - ConsoleUseV2UserApi: req.ConsoleUseV2UserApi, + LoginDefaultOrg: req.LoginDefaultOrg, + UserSchema: req.UserSchema, + TokenExchange: req.OidcTokenExchange, + ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + DebugOIDCParentError: req.DebugOidcParentError, + OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, + DisableUserTokenEvent: req.DisableUserTokenEvent, + EnableBackChannelLogout: req.EnableBackChannelLogout, + LoginV2: loginV2, + PermissionCheckV2: req.PermissionCheckV2, + ConsoleUseV2UserApi: req.ConsoleUseV2UserApi, }, nil } 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), - UserSchema: featureSourceToFlagPb(&f.UserSchema), - OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), - OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), - DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), - EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), - LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), - PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), - ConsoleUseV2UserApi: featureSourceToFlagPb(&f.ConsoleUseV2UserApi), + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + UserSchema: featureSourceToFlagPb(&f.UserSchema), + OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), + ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), + OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), + DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), + EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), + LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), + PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), + ConsoleUseV2UserApi: featureSourceToFlagPb(&f.ConsoleUseV2UserApi), } } diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index d09f1839ba..7b11fc0d17 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -19,24 +19,22 @@ import ( func Test_systemFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetSystemFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - OidcTokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OidcSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OidcTokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OidcSingleV1SessionTermination: gu.Ptr(true), LoginV2: &feature_pb.LoginV2{ Required: true, BaseUri: gu.Ptr("https://login.com"), }, } want := &command.SystemFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - TokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + TokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OIDCSingleV1SessionTermination: gu.Ptr(true), LoginV2: &feature.LoginV2{ Required: true, BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, @@ -58,10 +56,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - TriggerIntrospectionProjections: query.FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelSystem, Value: true, @@ -104,10 +98,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ - Enabled: false, - Source: feature_pb.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, @@ -148,14 +138,13 @@ func Test_systemFeaturesToPb(t *testing.T) { func Test_instanceFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetInstanceFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - OidcTokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - DebugOidcParentError: gu.Ptr(true), - OidcSingleV1SessionTermination: gu.Ptr(true), - EnableBackChannelLogout: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OidcTokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + DebugOidcParentError: gu.Ptr(true), + OidcSingleV1SessionTermination: gu.Ptr(true), + EnableBackChannelLogout: gu.Ptr(true), LoginV2: &feature_pb.LoginV2{ Required: true, BaseUri: gu.Ptr("https://login.com"), @@ -163,14 +152,13 @@ func Test_instanceFeaturesToCommand(t *testing.T) { ConsoleUseV2UserApi: gu.Ptr(true), } want := &command.InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - TokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - DebugOIDCParentError: gu.Ptr(true), - OIDCSingleV1SessionTermination: gu.Ptr(true), - EnableBackChannelLogout: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + TokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + DebugOIDCParentError: gu.Ptr(true), + OIDCSingleV1SessionTermination: gu.Ptr(true), + EnableBackChannelLogout: gu.Ptr(true), LoginV2: &feature.LoginV2{ Required: true, BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, @@ -193,10 +181,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - TriggerIntrospectionProjections: query.FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -243,10 +227,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ - Enabled: false, - Source: feature_pb.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_INSTANCE, diff --git a/internal/api/grpc/feature/v2/integration_test/feature_test.go b/internal/api/grpc/feature/v2/integration_test/feature_test.go index fe09242429..369f5b37b8 100644 --- a/internal/api/grpc/feature/v2/integration_test/feature_test.go +++ b/internal/api/grpc/feature/v2/integration_test/feature_test.go @@ -58,7 +58,7 @@ func TestServer_SetSystemFeatures(t *testing.T) { args: args{ ctx: IamCTX, req: &feature.SetSystemFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, wantErr: true, @@ -76,7 +76,7 @@ func TestServer_SetSystemFeatures(t *testing.T) { args: args{ ctx: SystemCTX, req: &feature.SetSystemFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, want: &feature.SetSystemFeaturesResponse{ @@ -170,8 +170,8 @@ func TestServer_GetSystemFeatures(t *testing.T) { name: "some features", prepare: func(t *testing.T) { _, err := Client.SetSystemFeatures(SystemCTX, &feature.SetSystemFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(false), }) require.NoError(t, err) }, @@ -184,7 +184,7 @@ func TestServer_GetSystemFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ + UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_SYSTEM, }, @@ -208,7 +208,6 @@ func TestServer_GetSystemFeatures(t *testing.T) { } require.NoError(t, err) assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) - assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) }) } @@ -230,7 +229,7 @@ func TestServer_SetInstanceFeatures(t *testing.T) { args: args{ ctx: OrgCTX, req: &feature.SetInstanceFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, wantErr: true, @@ -248,7 +247,7 @@ func TestServer_SetInstanceFeatures(t *testing.T) { args: args{ ctx: IamCTX, req: &feature.SetInstanceFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, want: &feature.SetInstanceFeaturesResponse{ @@ -360,10 +359,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -374,9 +369,8 @@ func TestServer_GetInstanceFeatures(t *testing.T) { 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), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), }) require.NoError(t, err) }, @@ -389,10 +383,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { 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, @@ -418,10 +408,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_INSTANCE, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -446,7 +432,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { } require.NoError(t, err) assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) - assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) }) } diff --git a/internal/api/grpc/feature/v2beta/converter.go b/internal/api/grpc/feature/v2beta/converter.go index 8927b16e29..dc791d4c51 100644 --- a/internal/api/grpc/feature/v2beta/converter.go +++ b/internal/api/grpc/feature/v2beta/converter.go @@ -10,49 +10,45 @@ import ( func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command.SystemFeatures { return &command.SystemFeatures{ - LoginDefaultOrg: req.LoginDefaultOrg, - TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - UserSchema: req.UserSchema, - TokenExchange: req.OidcTokenExchange, - ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, + LoginDefaultOrg: req.LoginDefaultOrg, + UserSchema: req.UserSchema, + TokenExchange: req.OidcTokenExchange, + ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, } } 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), - UserSchema: featureSourceToFlagPb(&f.UserSchema), - OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + UserSchema: featureSourceToFlagPb(&f.UserSchema), + OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), + ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), } } func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *command.InstanceFeatures { return &command.InstanceFeatures{ - LoginDefaultOrg: req.LoginDefaultOrg, - TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - UserSchema: req.UserSchema, - TokenExchange: req.OidcTokenExchange, - ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - DebugOIDCParentError: req.DebugOidcParentError, - OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, + LoginDefaultOrg: req.LoginDefaultOrg, + UserSchema: req.UserSchema, + TokenExchange: req.OidcTokenExchange, + ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + DebugOIDCParentError: req.DebugOidcParentError, + OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, } } 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), - UserSchema: featureSourceToFlagPb(&f.UserSchema), - OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), - OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + UserSchema: featureSourceToFlagPb(&f.UserSchema), + OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), + ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), + OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), } } diff --git a/internal/api/grpc/feature/v2beta/converter_test.go b/internal/api/grpc/feature/v2beta/converter_test.go index 5fdb5e993e..ec681011f0 100644 --- a/internal/api/grpc/feature/v2beta/converter_test.go +++ b/internal/api/grpc/feature/v2beta/converter_test.go @@ -18,20 +18,18 @@ import ( func Test_systemFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetSystemFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - OidcTokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OidcSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OidcTokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OidcSingleV1SessionTermination: gu.Ptr(true), } want := &command.SystemFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - TokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + TokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OIDCSingleV1SessionTermination: gu.Ptr(true), } got := systemFeaturesToCommand(arg) assert.Equal(t, want, got) @@ -48,10 +46,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - TriggerIntrospectionProjections: query.FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelSystem, Value: true, @@ -79,10 +73,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ - Enabled: false, - Source: feature_pb.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, @@ -106,20 +96,18 @@ func Test_systemFeaturesToPb(t *testing.T) { func Test_instanceFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetInstanceFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - OidcTokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OidcSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OidcTokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OidcSingleV1SessionTermination: gu.Ptr(true), } want := &command.InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - TokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + TokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OIDCSingleV1SessionTermination: gu.Ptr(true), } got := instanceFeaturesToCommand(arg) assert.Equal(t, want, got) @@ -136,10 +124,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - TriggerIntrospectionProjections: query.FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -167,10 +151,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ - Enabled: false, - Source: feature_pb.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_INSTANCE, diff --git a/internal/api/grpc/feature/v2beta/integration_test/feature_test.go b/internal/api/grpc/feature/v2beta/integration_test/feature_test.go index 549bc4ef0a..4e24bb2a4f 100644 --- a/internal/api/grpc/feature/v2beta/integration_test/feature_test.go +++ b/internal/api/grpc/feature/v2beta/integration_test/feature_test.go @@ -61,7 +61,7 @@ func TestServer_SetInstanceFeatures(t *testing.T) { args: args{ ctx: OrgCTX, req: &feature.SetInstanceFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, wantErr: true, @@ -79,7 +79,7 @@ func TestServer_SetInstanceFeatures(t *testing.T) { args: args{ ctx: IamCTX, req: &feature.SetInstanceFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, want: &feature.SetInstanceFeaturesResponse{ @@ -190,10 +190,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -204,9 +200,8 @@ func TestServer_GetInstanceFeatures(t *testing.T) { 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), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), }) require.NoError(t, err) }, @@ -219,10 +214,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { 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, @@ -248,10 +239,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_INSTANCE, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -276,7 +263,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { } require.NoError(t, err) assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) - assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) }) } diff --git a/internal/api/oidc/access_token.go b/internal/api/oidc/access_token.go index 5c0b9c9f66..08337bb5af 100644 --- a/internal/api/oidc/access_token.go +++ b/internal/api/oidc/access_token.go @@ -11,7 +11,6 @@ import ( "github.com/zitadel/oidc/v3/pkg/op" "golang.org/x/text/language" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" @@ -122,7 +121,7 @@ func (s *Server) assertClientScopesForPAT(ctx context.Context, token *accessToke if err != nil { return zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") } - roles, err := s.query.SearchProjectRoles(ctx, authz.GetFeatures(ctx).TriggerIntrospectionProjections, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) + roles, err := s.query.SearchProjectRoles(ctx, false, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) if err != nil { return err } diff --git a/internal/api/oidc/integration_test/userinfo_test.go b/internal/api/oidc/integration_test/userinfo_test.go index b3bc836343..2a31dd964b 100644 --- a/internal/api/oidc/integration_test/userinfo_test.go +++ b/internal/api/oidc/integration_test/userinfo_test.go @@ -18,48 +18,11 @@ import ( oidc_api "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/integration" - "github.com/zitadel/zitadel/pkg/grpc/feature/v2" "github.com/zitadel/zitadel/pkg/grpc/management" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) -// TestServer_UserInfo is a top-level test which re-executes the actual -// userinfo integration test against a matrix of different feature flags. -// This ensure that the response of the different implementations remains the same. func TestServer_UserInfo(t *testing.T) { - iamOwnerCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - t.Cleanup(func() { - _, err := Instance.Client.FeatureV2.ResetInstanceFeatures(iamOwnerCTX, &feature.ResetInstanceFeaturesRequest{}) - require.NoError(t, err) - }) - tests := []struct { - name string - trigger bool - }{ - { - name: "trigger enabled", - trigger: true, - }, - { - name: "trigger disabled", - trigger: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := Instance.Client.FeatureV2.SetInstanceFeatures(iamOwnerCTX, &feature.SetInstanceFeaturesRequest{ - OidcTriggerIntrospectionProjections: &tt.trigger, - }) - require.NoError(t, err) - testServer_UserInfo(t) - }) - } -} - -// testServer_UserInfo is the actual userinfo integration test, -// which calls the userinfo endpoint with different client configurations, roles and token scopes. -func testServer_UserInfo(t *testing.T) { const ( roleFoo = "foo" roleBar = "bar" diff --git a/internal/api/oidc/introspect.go b/internal/api/oidc/introspect.go index ee022eb3e9..e5479a4683 100644 --- a/internal/api/oidc/introspect.go +++ b/internal/api/oidc/introspect.go @@ -23,12 +23,6 @@ func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionR err = oidcError(err) span.EndWithError(err) }() - - features := authz.GetFeatures(ctx) - if features.TriggerIntrospectionProjections { - query.TriggerIntrospectionProjections(ctx) - } - ctx, cancel := context.WithCancel(ctx) defer cancel() diff --git a/internal/api/oidc/userinfo.go b/internal/api/oidc/userinfo.go index 5266500e7a..170ff49c94 100644 --- a/internal/api/oidc/userinfo.go +++ b/internal/api/oidc/userinfo.go @@ -33,12 +33,6 @@ func (s *Server) UserInfo(ctx context.Context, r *op.Request[oidc.UserInfoReques err = oidcError(err) span.EndWithError(err) }() - - features := authz.GetFeatures(ctx) - if features.TriggerIntrospectionProjections { - query.TriggerOIDCUserInfoProjections(ctx) - } - token, err := s.verifyAccessToken(ctx, r.Data.AccessToken) if err != nil { return nil, op.NewStatusError(oidc.ErrAccessDenied().WithDescription("access token invalid").WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError), http.StatusUnauthorized) diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go index 21de5653a9..04f2621705 100644 --- a/internal/command/instance_features.go +++ b/internal/command/instance_features.go @@ -13,23 +13,21 @@ import ( ) type InstanceFeatures struct { - LoginDefaultOrg *bool - TriggerIntrospectionProjections *bool - UserSchema *bool - TokenExchange *bool - ImprovedPerformance []feature.ImprovedPerformanceType - DebugOIDCParentError *bool - OIDCSingleV1SessionTermination *bool - DisableUserTokenEvent *bool - EnableBackChannelLogout *bool - LoginV2 *feature.LoginV2 - PermissionCheckV2 *bool - ConsoleUseV2UserApi *bool + LoginDefaultOrg *bool + UserSchema *bool + TokenExchange *bool + ImprovedPerformance []feature.ImprovedPerformanceType + DebugOIDCParentError *bool + OIDCSingleV1SessionTermination *bool + DisableUserTokenEvent *bool + EnableBackChannelLogout *bool + LoginV2 *feature.LoginV2 + PermissionCheckV2 *bool + ConsoleUseV2UserApi *bool } func (m *InstanceFeatures) isEmpty() bool { return m.LoginDefaultOrg == nil && - m.TriggerIntrospectionProjections == nil && m.UserSchema == nil && m.TokenExchange == nil && // nil check to allow unset improvements diff --git a/internal/command/instance_features_model.go b/internal/command/instance_features_model.go index 8ca2865eae..8fe9dd0284 100644 --- a/internal/command/instance_features_model.go +++ b/internal/command/instance_features_model.go @@ -67,7 +67,6 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v1.DefaultLoginInstanceEventType, feature_v2.InstanceResetEventType, feature_v2.InstanceLoginDefaultOrgEventType, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, feature_v2.InstanceImprovedPerformanceEventType, @@ -93,9 +92,6 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an case feature.KeyLoginDefaultOrg: v := value.(bool) features.LoginDefaultOrg = &v - case feature.KeyTriggerIntrospectionProjections: - v := value.(bool) - features.TriggerIntrospectionProjections = &v case feature.KeyTokenExchange: v := value.(bool) features.TokenExchange = &v @@ -132,7 +128,6 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan aggregate := feature_v2.NewAggregate(wm.AggregateID, wm.ResourceOwner) cmds := make([]eventstore.Command, 0, len(feature.KeyValues())-1) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.InstanceLoginDefaultOrgEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.InstanceTriggerIntrospectionProjectionsEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.InstanceTokenExchangeEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.InstanceUserSchemaEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.InstanceImprovedPerformanceEventType) diff --git a/internal/command/instance_features_test.go b/internal/command/instance_features_test.go index 8d0c7d5964..f0bea9752d 100644 --- a/internal/command/instance_features_test.go +++ b/internal/command/instance_features_test.go @@ -95,24 +95,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ResourceOwner: "instance1", }, }, - { - name: "set TriggerIntrospectionProjections", - eventstore: expectEventstore( - expectFilter(), - expectPush( - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - ), - ), - ), - args: args{ctx, &InstanceFeatures{ - TriggerIntrospectionProjections: gu.Ptr(true), - }}, - want: &domain.ObjectDetails{ - ResourceOwner: "instance1", - }, - }, { name: "set UserSchema", eventstore: expectEventstore( @@ -156,10 +138,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, true, ), - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, false, - ), feature_v2.NewSetEvent[bool]( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, true, @@ -171,10 +149,9 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ), ), args: args{ctx, &InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OIDCSingleV1SessionTermination: gu.Ptr(true), }}, want: &domain.ObjectDetails{ ResourceOwner: "instance1", @@ -189,10 +166,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, false, - )), eventFromEventPusher(feature_v2.NewResetEvent( ctx, aggregate, feature_v2.InstanceResetEventType, @@ -211,16 +184,11 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, true, ), - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, false, - ), ), ), args: args{ctx, &InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - OIDCSingleV1SessionTermination: gu.Ptr(false), + LoginDefaultOrg: gu.Ptr(true), + OIDCSingleV1SessionTermination: gu.Ptr(false), }}, want: &domain.ObjectDetails{ ResourceOwner: "instance1", diff --git a/internal/command/system_features.go b/internal/command/system_features.go index f20c9f3cda..c2d4f9f9e7 100644 --- a/internal/command/system_features.go +++ b/internal/command/system_features.go @@ -10,21 +10,19 @@ import ( ) type SystemFeatures struct { - LoginDefaultOrg *bool - TriggerIntrospectionProjections *bool - TokenExchange *bool - UserSchema *bool - ImprovedPerformance []feature.ImprovedPerformanceType - OIDCSingleV1SessionTermination *bool - DisableUserTokenEvent *bool - EnableBackChannelLogout *bool - LoginV2 *feature.LoginV2 - PermissionCheckV2 *bool + LoginDefaultOrg *bool + TokenExchange *bool + UserSchema *bool + ImprovedPerformance []feature.ImprovedPerformanceType + OIDCSingleV1SessionTermination *bool + DisableUserTokenEvent *bool + EnableBackChannelLogout *bool + LoginV2 *feature.LoginV2 + PermissionCheckV2 *bool } func (m *SystemFeatures) isEmpty() bool { return m.LoginDefaultOrg == nil && - m.TriggerIntrospectionProjections == nil && m.UserSchema == nil && m.TokenExchange == nil && // nil check to allow unset improvements diff --git a/internal/command/system_features_model.go b/internal/command/system_features_model.go index f1e6ba6357..212d00e6ce 100644 --- a/internal/command/system_features_model.go +++ b/internal/command/system_features_model.go @@ -60,7 +60,6 @@ func (m *SystemFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { EventTypes( feature_v2.SystemResetEventType, feature_v2.SystemLoginDefaultOrgEventType, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, feature_v2.SystemUserSchemaEventType, feature_v2.SystemTokenExchangeEventType, feature_v2.SystemImprovedPerformanceEventType, @@ -84,9 +83,6 @@ func reduceSystemFeature(features *SystemFeatures, key feature.Key, value any) { case feature.KeyLoginDefaultOrg: v := value.(bool) features.LoginDefaultOrg = &v - case feature.KeyTriggerIntrospectionProjections: - v := value.(bool) - features.TriggerIntrospectionProjections = &v case feature.KeyUserSchema: v := value.(bool) features.UserSchema = &v @@ -116,7 +112,6 @@ func (wm *SystemFeaturesWriteModel) setCommands(ctx context.Context, f *SystemFe aggregate := feature_v2.NewAggregate(wm.AggregateID, wm.ResourceOwner) cmds := make([]eventstore.Command, 0, len(feature.KeyValues())-1) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.SystemLoginDefaultOrgEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.SystemTriggerIntrospectionProjectionsEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.SystemUserSchemaEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.SystemTokenExchangeEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.SystemImprovedPerformanceEventType) diff --git a/internal/command/system_features_test.go b/internal/command/system_features_test.go index ff6aef8104..2defd23d5e 100644 --- a/internal/command/system_features_test.go +++ b/internal/command/system_features_test.go @@ -63,24 +63,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { ResourceOwner: "SYSTEM", }, }, - { - name: "set TriggerIntrospectionProjections", - eventstore: expectEventstore( - expectFilter(), - expectPush( - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - ), - ), - ), - args: args{context.Background(), &SystemFeatures{ - TriggerIntrospectionProjections: gu.Ptr(true), - }}, - want: &domain.ObjectDetails{ - ResourceOwner: "SYSTEM", - }, - }, { name: "set UserSchema", eventstore: expectEventstore( @@ -124,10 +106,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, true, ), - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, false, - ), feature_v2.NewSetEvent[bool]( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, true, @@ -139,10 +117,9 @@ func TestCommands_SetSystemFeatures(t *testing.T) { ), ), args: args{context.Background(), &SystemFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OIDCSingleV1SessionTermination: gu.Ptr(true), }}, want: &domain.ObjectDetails{ ResourceOwner: "SYSTEM", @@ -157,10 +134,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, false, - )), eventFromEventPusher(feature_v2.NewResetEvent( context.Background(), aggregate, feature_v2.SystemResetEventType, @@ -175,10 +148,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, true, ), - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, false, - ), feature_v2.NewSetEvent[bool]( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, true, @@ -190,10 +159,9 @@ func TestCommands_SetSystemFeatures(t *testing.T) { ), ), args: args{context.Background(), &SystemFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - OIDCSingleV1SessionTermination: gu.Ptr(false), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OIDCSingleV1SessionTermination: gu.Ptr(false), }}, want: &domain.ObjectDetails{ ResourceOwner: "SYSTEM", diff --git a/internal/feature/feature.go b/internal/feature/feature.go index 107b06edf1..5e28338904 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -9,21 +9,20 @@ import ( type Key int const ( - // Reserved: 3, 6, 8 + // Reserved: 2, 3, 6, 8 - KeyUnspecified Key = 0 - KeyLoginDefaultOrg Key = 1 - KeyTriggerIntrospectionProjections Key = 2 - KeyUserSchema Key = 4 - KeyTokenExchange Key = 5 - KeyImprovedPerformance Key = 7 - KeyDebugOIDCParentError Key = 9 - KeyOIDCSingleV1SessionTermination Key = 10 - KeyDisableUserTokenEvent Key = 11 - KeyEnableBackChannelLogout Key = 12 - KeyLoginV2 Key = 13 - KeyPermissionCheckV2 Key = 14 - KeyConsoleUseV2UserApi Key = 15 + KeyUnspecified Key = 0 + KeyLoginDefaultOrg Key = 1 + KeyUserSchema Key = 4 + KeyTokenExchange Key = 5 + KeyImprovedPerformance Key = 7 + KeyDebugOIDCParentError Key = 9 + KeyOIDCSingleV1SessionTermination Key = 10 + KeyDisableUserTokenEvent Key = 11 + KeyEnableBackChannelLogout Key = 12 + KeyLoginV2 Key = 13 + KeyPermissionCheckV2 Key = 14 + KeyConsoleUseV2UserApi Key = 15 ) //go:generate enumer -type Level -transform snake -trimprefix Level @@ -40,18 +39,17 @@ const ( ) type Features struct { - LoginDefaultOrg bool `json:"login_default_org,omitempty"` - TriggerIntrospectionProjections bool `json:"trigger_introspection_projections,omitempty"` - UserSchema bool `json:"user_schema,omitempty"` - TokenExchange bool `json:"token_exchange,omitempty"` - ImprovedPerformance []ImprovedPerformanceType `json:"improved_performance,omitempty"` - DebugOIDCParentError bool `json:"debug_oidc_parent_error,omitempty"` - OIDCSingleV1SessionTermination bool `json:"oidc_single_v1_session_termination,omitempty"` - DisableUserTokenEvent bool `json:"disable_user_token_event,omitempty"` - EnableBackChannelLogout bool `json:"enable_back_channel_logout,omitempty"` - LoginV2 LoginV2 `json:"login_v2,omitempty"` - PermissionCheckV2 bool `json:"permission_check_v2,omitempty"` - ConsoleUseV2UserApi bool `json:"console_use_v2_user_api,omitempty"` + LoginDefaultOrg bool `json:"login_default_org,omitempty"` + UserSchema bool `json:"user_schema,omitempty"` + TokenExchange bool `json:"token_exchange,omitempty"` + ImprovedPerformance []ImprovedPerformanceType `json:"improved_performance,omitempty"` + DebugOIDCParentError bool `json:"debug_oidc_parent_error,omitempty"` + OIDCSingleV1SessionTermination bool `json:"oidc_single_v1_session_termination,omitempty"` + DisableUserTokenEvent bool `json:"disable_user_token_event,omitempty"` + EnableBackChannelLogout bool `json:"enable_back_channel_logout,omitempty"` + LoginV2 LoginV2 `json:"login_v2,omitempty"` + PermissionCheckV2 bool `json:"permission_check_v2,omitempty"` + ConsoleUseV2UserApi bool `json:"console_use_v2_user_api,omitempty"` } /* Note: do not generate the stringer or enumer for this type, is it breaks existing events */ diff --git a/internal/feature/feature_test.go b/internal/feature/feature_test.go index abb8968d6f..d9a459e3db 100644 --- a/internal/feature/feature_test.go +++ b/internal/feature/feature_test.go @@ -11,7 +11,6 @@ func TestKey(t *testing.T) { tests := []string{ "unspecified", "login_default_org", - "trigger_introspection_projections", } for _, want := range tests { t.Run(want, func(t *testing.T) { diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index 1b4fb9a3ad..9d6f5877e0 100644 --- a/internal/feature/key_enumer.go +++ b/internal/feature/key_enumer.go @@ -8,8 +8,8 @@ import ( ) const ( - _KeyName_0 = "unspecifiedlogin_default_orgtrigger_introspection_projections" - _KeyLowerName_0 = "unspecifiedlogin_default_orgtrigger_introspection_projections" + _KeyName_0 = "unspecifiedlogin_default_org" + _KeyLowerName_0 = "unspecifiedlogin_default_org" _KeyName_1 = "user_schematoken_exchange" _KeyLowerName_1 = "user_schematoken_exchange" _KeyName_2 = "improved_performance" @@ -19,7 +19,7 @@ const ( ) var ( - _KeyIndex_0 = [...]uint8{0, 11, 28, 61} + _KeyIndex_0 = [...]uint8{0, 11, 28} _KeyIndex_1 = [...]uint8{0, 11, 25} _KeyIndex_2 = [...]uint8{0, 20} _KeyIndex_3 = [...]uint8{0, 23, 57, 81, 107, 115, 134, 157} @@ -27,7 +27,7 @@ var ( func (i Key) String() string { switch { - case 0 <= i && i <= 2: + case 0 <= i && i <= 1: return _KeyName_0[_KeyIndex_0[i]:_KeyIndex_0[i+1]] case 4 <= i && i <= 5: i -= 4 @@ -48,7 +48,6 @@ func _KeyNoOp() { var x [1]struct{} _ = x[KeyUnspecified-(0)] _ = x[KeyLoginDefaultOrg-(1)] - _ = x[KeyTriggerIntrospectionProjections-(2)] _ = x[KeyUserSchema-(4)] _ = x[KeyTokenExchange-(5)] _ = x[KeyImprovedPerformance-(7)] @@ -61,15 +60,13 @@ func _KeyNoOp() { _ = x[KeyConsoleUseV2UserApi-(15)] } -var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyUserSchema, KeyTokenExchange, KeyImprovedPerformance, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyUserSchema, KeyTokenExchange, KeyImprovedPerformance, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} var _KeyNameToValueMap = map[string]Key{ _KeyName_0[0:11]: KeyUnspecified, _KeyLowerName_0[0:11]: KeyUnspecified, _KeyName_0[11:28]: KeyLoginDefaultOrg, _KeyLowerName_0[11:28]: KeyLoginDefaultOrg, - _KeyName_0[28:61]: KeyTriggerIntrospectionProjections, - _KeyLowerName_0[28:61]: KeyTriggerIntrospectionProjections, _KeyName_1[0:11]: KeyUserSchema, _KeyLowerName_1[0:11]: KeyUserSchema, _KeyName_1[11:25]: KeyTokenExchange, @@ -95,7 +92,6 @@ var _KeyNameToValueMap = map[string]Key{ var _KeyNames = []string{ _KeyName_0[0:11], _KeyName_0[11:28], - _KeyName_0[28:61], _KeyName_1[0:11], _KeyName_1[11:25], _KeyName_2[0:20], diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go index 9e0081a542..73f51bfdf7 100644 --- a/internal/query/instance_features.go +++ b/internal/query/instance_features.go @@ -8,19 +8,18 @@ import ( ) type InstanceFeatures struct { - Details *domain.ObjectDetails - LoginDefaultOrg FeatureSource[bool] - TriggerIntrospectionProjections FeatureSource[bool] - UserSchema FeatureSource[bool] - TokenExchange FeatureSource[bool] - ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] - DebugOIDCParentError FeatureSource[bool] - OIDCSingleV1SessionTermination FeatureSource[bool] - DisableUserTokenEvent FeatureSource[bool] - EnableBackChannelLogout FeatureSource[bool] - LoginV2 FeatureSource[*feature.LoginV2] - PermissionCheckV2 FeatureSource[bool] - ConsoleUseV2UserApi FeatureSource[bool] + Details *domain.ObjectDetails + LoginDefaultOrg FeatureSource[bool] + UserSchema FeatureSource[bool] + TokenExchange FeatureSource[bool] + ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] + DebugOIDCParentError FeatureSource[bool] + OIDCSingleV1SessionTermination FeatureSource[bool] + DisableUserTokenEvent FeatureSource[bool] + EnableBackChannelLogout FeatureSource[bool] + LoginV2 FeatureSource[*feature.LoginV2] + PermissionCheckV2 FeatureSource[bool] + ConsoleUseV2UserApi FeatureSource[bool] } func (q *Queries) GetInstanceFeatures(ctx context.Context, cascade bool) (_ *InstanceFeatures, err error) { diff --git a/internal/query/instance_features_model.go b/internal/query/instance_features_model.go index a30009e9ee..fa0f638bed 100644 --- a/internal/query/instance_features_model.go +++ b/internal/query/instance_features_model.go @@ -63,7 +63,6 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v1.DefaultLoginInstanceEventType, feature_v2.InstanceResetEventType, feature_v2.InstanceLoginDefaultOrgEventType, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, feature_v2.InstanceImprovedPerformanceEventType, @@ -91,7 +90,6 @@ func (m *InstanceFeaturesReadModel) populateFromSystem() bool { return false } m.instance.LoginDefaultOrg = m.system.LoginDefaultOrg - m.instance.TriggerIntrospectionProjections = m.system.TriggerIntrospectionProjections m.instance.UserSchema = m.system.UserSchema m.instance.TokenExchange = m.system.TokenExchange m.instance.ImprovedPerformance = m.system.ImprovedPerformance @@ -112,8 +110,6 @@ func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_ return nil case feature.KeyLoginDefaultOrg: features.LoginDefaultOrg.set(level, event.Value) - case feature.KeyTriggerIntrospectionProjections: - features.TriggerIntrospectionProjections.set(level, event.Value) case feature.KeyUserSchema: features.UserSchema.set(level, event.Value) case feature.KeyTokenExchange: diff --git a/internal/query/instance_features_test.go b/internal/query/instance_features_test.go index af662e4898..5c5f8ecc64 100644 --- a/internal/query/instance_features_test.go +++ b/internal/query/instance_features_test.go @@ -71,10 +71,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, }, }, { @@ -89,10 +85,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - )), eventFromEventPusher(feature_v2.NewSetEvent( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, @@ -108,10 +100,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelInstance, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelInstance, Value: false, @@ -130,10 +118,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - )), eventFromEventPusher(feature_v2.NewSetEvent( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, @@ -142,10 +126,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceResetEventType, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - )), ), ), args: args{true}, @@ -157,10 +137,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, @@ -175,10 +151,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - )), eventFromEventPusher(feature_v2.NewSetEvent( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, @@ -187,10 +159,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceResetEventType, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - )), ), ), args: args{false}, @@ -202,10 +170,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, diff --git a/internal/query/introspection.go b/internal/query/introspection.go index ee96bf576b..a3ef125466 100644 --- a/internal/query/introspection.go +++ b/internal/query/introspection.go @@ -25,12 +25,6 @@ var introspectionTriggerHandlers = sync.OnceValue(func() []*handler.Handler { ) }) -// TriggerIntrospectionProjections triggers all projections -// relevant to introspection queries concurrently. -func TriggerIntrospectionProjections(ctx context.Context) { - triggerBatch(ctx, introspectionTriggerHandlers()...) -} - type AppType string const ( diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go index 3c33ff6fdf..32ec2cf111 100644 --- a/internal/query/projection/instance_features.go +++ b/internal/query/projection/instance_features.go @@ -64,10 +64,6 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.InstanceLoginDefaultOrgEventType, Reduce: reduceInstanceSetFeature[bool], }, - { - Event: feature_v2.InstanceTriggerIntrospectionProjectionsEventType, - Reduce: reduceInstanceSetFeature[bool], - }, { Event: feature_v2.InstanceUserSchemaEventType, Reduce: reduceInstanceSetFeature[bool], diff --git a/internal/query/projection/system_features.go b/internal/query/projection/system_features.go index 3f70f7dfa6..32f49108e6 100644 --- a/internal/query/projection/system_features.go +++ b/internal/query/projection/system_features.go @@ -56,10 +56,6 @@ func (*systemFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.SystemLoginDefaultOrgEventType, Reduce: reduceSystemSetFeature[bool], }, - { - Event: feature_v2.SystemTriggerIntrospectionProjectionsEventType, - Reduce: reduceSystemSetFeature[bool], - }, { Event: feature_v2.SystemUserSchemaEventType, Reduce: reduceSystemSetFeature[bool], diff --git a/internal/query/system_features.go b/internal/query/system_features.go index 8c340ce739..34410cb9b8 100644 --- a/internal/query/system_features.go +++ b/internal/query/system_features.go @@ -20,16 +20,15 @@ func (f *FeatureSource[T]) set(level feature.Level, value any) { type SystemFeatures struct { Details *domain.ObjectDetails - LoginDefaultOrg FeatureSource[bool] - TriggerIntrospectionProjections FeatureSource[bool] - UserSchema FeatureSource[bool] - TokenExchange FeatureSource[bool] - ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] - OIDCSingleV1SessionTermination FeatureSource[bool] - DisableUserTokenEvent FeatureSource[bool] - EnableBackChannelLogout FeatureSource[bool] - LoginV2 FeatureSource[*feature.LoginV2] - PermissionCheckV2 FeatureSource[bool] + LoginDefaultOrg FeatureSource[bool] + UserSchema FeatureSource[bool] + TokenExchange FeatureSource[bool] + ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] + OIDCSingleV1SessionTermination FeatureSource[bool] + DisableUserTokenEvent FeatureSource[bool] + EnableBackChannelLogout FeatureSource[bool] + LoginV2 FeatureSource[*feature.LoginV2] + PermissionCheckV2 FeatureSource[bool] } func (q *Queries) GetSystemFeatures(ctx context.Context) (_ *SystemFeatures, err error) { diff --git a/internal/query/system_features_model.go b/internal/query/system_features_model.go index f91bc7d1e9..67045f314d 100644 --- a/internal/query/system_features_model.go +++ b/internal/query/system_features_model.go @@ -56,7 +56,6 @@ func (m *SystemFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { EventTypes( feature_v2.SystemResetEventType, feature_v2.SystemLoginDefaultOrgEventType, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, feature_v2.SystemUserSchemaEventType, feature_v2.SystemTokenExchangeEventType, feature_v2.SystemImprovedPerformanceEventType, @@ -84,8 +83,6 @@ func reduceSystemFeatureSet[T any](features *SystemFeatures, event *feature_v2.S return nil case feature.KeyLoginDefaultOrg: features.LoginDefaultOrg.set(level, event.Value) - case feature.KeyTriggerIntrospectionProjections: - features.TriggerIntrospectionProjections.set(level, event.Value) case feature.KeyUserSchema: features.UserSchema.set(level, event.Value) case feature.KeyTokenExchange: diff --git a/internal/query/system_features_test.go b/internal/query/system_features_test.go index da59ceb549..7aa12a6a8f 100644 --- a/internal/query/system_features_test.go +++ b/internal/query/system_features_test.go @@ -49,10 +49,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - )), eventFromEventPusher(feature_v2.NewSetEvent( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, @@ -67,10 +63,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelSystem, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelSystem, Value: false, @@ -85,10 +77,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - )), eventFromEventPusher(feature_v2.NewSetEvent( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, @@ -97,10 +85,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemResetEventType, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - )), ), ), want: &SystemFeatures{ @@ -111,10 +95,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, @@ -129,10 +109,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - )), eventFromEventPusher(feature_v2.NewSetEvent( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, @@ -141,10 +117,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemResetEventType, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - )), ), ), want: &SystemFeatures{ @@ -155,10 +127,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, diff --git a/internal/query/userinfo.go b/internal/query/userinfo.go index 0e749f09b3..aa2920dfba 100644 --- a/internal/query/userinfo.go +++ b/internal/query/userinfo.go @@ -31,12 +31,6 @@ var oidcUserInfoTriggerHandlers = sync.OnceValue(func() []*handler.Handler { } }) -// TriggerOIDCUserInfoProjections triggers all projections -// relevant to userinfo queries concurrently. -func TriggerOIDCUserInfoProjections(ctx context.Context) { - triggerBatch(ctx, oidcUserInfoTriggerHandlers()...) -} - var ( //go:embed userinfo_by_id.sql oidcUserInfoQueryTmpl string diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go index 25d0f270f6..293c1ee3cd 100644 --- a/internal/repository/feature/feature_v2/eventstore.go +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -8,7 +8,6 @@ import ( func init() { eventstore.RegisterFilterEventMapper(AggregateType, SystemResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, SystemLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) - eventstore.RegisterFilterEventMapper(AggregateType, SystemTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) @@ -20,7 +19,6 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, InstanceResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) - eventstore.RegisterFilterEventMapper(AggregateType, InstanceTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go index a87042d72a..2859b65ebf 100644 --- a/internal/repository/feature/feature_v2/feature.go +++ b/internal/repository/feature/feature_v2/feature.go @@ -11,31 +11,29 @@ import ( ) var ( - SystemResetEventType = resetEventTypeFromFeature(feature.LevelSystem) - SystemLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginDefaultOrg) - SystemTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTriggerIntrospectionProjections) - SystemUserSchemaEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyUserSchema) - SystemTokenExchangeEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTokenExchange) - SystemImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyImprovedPerformance) - SystemOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyOIDCSingleV1SessionTermination) - SystemDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelSystem, feature.KeyDisableUserTokenEvent) - SystemEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelSystem, feature.KeyEnableBackChannelLogout) - SystemLoginVersion = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginV2) - SystemPermissionCheckV2 = setEventTypeFromFeature(feature.LevelSystem, feature.KeyPermissionCheckV2) + SystemResetEventType = resetEventTypeFromFeature(feature.LevelSystem) + SystemLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginDefaultOrg) + SystemUserSchemaEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyUserSchema) + SystemTokenExchangeEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTokenExchange) + SystemImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyImprovedPerformance) + SystemOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyOIDCSingleV1SessionTermination) + SystemDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelSystem, feature.KeyDisableUserTokenEvent) + SystemEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelSystem, feature.KeyEnableBackChannelLogout) + SystemLoginVersion = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginV2) + SystemPermissionCheckV2 = setEventTypeFromFeature(feature.LevelSystem, feature.KeyPermissionCheckV2) - InstanceResetEventType = resetEventTypeFromFeature(feature.LevelInstance) - InstanceLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginDefaultOrg) - InstanceTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTriggerIntrospectionProjections) - InstanceUserSchemaEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyUserSchema) - InstanceTokenExchangeEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTokenExchange) - InstanceImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyImprovedPerformance) - InstanceDebugOIDCParentErrorEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDebugOIDCParentError) - InstanceOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyOIDCSingleV1SessionTermination) - InstanceDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDisableUserTokenEvent) - InstanceEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelInstance, feature.KeyEnableBackChannelLogout) - InstanceLoginVersion = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginV2) - InstancePermissionCheckV2 = setEventTypeFromFeature(feature.LevelInstance, feature.KeyPermissionCheckV2) - InstanceConsoleUseV2UserApi = setEventTypeFromFeature(feature.LevelInstance, feature.KeyConsoleUseV2UserApi) + InstanceResetEventType = resetEventTypeFromFeature(feature.LevelInstance) + InstanceLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginDefaultOrg) + InstanceUserSchemaEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyUserSchema) + InstanceTokenExchangeEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTokenExchange) + InstanceImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyImprovedPerformance) + InstanceDebugOIDCParentErrorEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDebugOIDCParentError) + InstanceOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyOIDCSingleV1SessionTermination) + InstanceDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDisableUserTokenEvent) + InstanceEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelInstance, feature.KeyEnableBackChannelLogout) + InstanceLoginVersion = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginV2) + InstancePermissionCheckV2 = setEventTypeFromFeature(feature.LevelInstance, feature.KeyPermissionCheckV2) + InstanceConsoleUseV2UserApi = setEventTypeFromFeature(feature.LevelInstance, feature.KeyConsoleUseV2UserApi) ) const ( diff --git a/proto/zitadel/feature/v2/instance.proto b/proto/zitadel/feature/v2/instance.proto index efbe5e3cdf..f3467f723d 100644 --- a/proto/zitadel/feature/v2/instance.proto +++ b/proto/zitadel/feature/v2/instance.proto @@ -11,20 +11,14 @@ import "zitadel/feature/v2/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2;feature"; message SetInstanceFeaturesRequest{ - reserved 3, 6, 8; - reserved "oidc_legacy_introspection", "actions", "web_key"; + reserved 2, 3, 6, 8; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions", "web_key"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; description: "The login UI will use the settings of the default org (and not from the instance) if no organization context is set"; } ]; - optional bool oidc_trigger_introspection_projections = 2 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -118,8 +112,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { - reserved 4, 7, 9; - reserved "oidc_legacy_introspection", "actions", "web_key"; + reserved 3, 4, 7, 9; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions", "web_key"; zitadel.object.v2.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -128,13 +122,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag oidc_trigger_introspection_projections = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2/system.proto b/proto/zitadel/feature/v2/system.proto index ac39e62f09..d3fbe6bccb 100644 --- a/proto/zitadel/feature/v2/system.proto +++ b/proto/zitadel/feature/v2/system.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2;feature"; message SetSystemFeaturesRequest{ - reserved 3, 6; - reserved "oidc_legacy_introspection", "actions"; + reserved 2, 3, 6; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -20,13 +20,6 @@ message SetSystemFeaturesRequest{ } ]; - optional bool oidc_trigger_introspection_projections = 2 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -98,8 +91,8 @@ message ResetSystemFeaturesResponse { message GetSystemFeaturesRequest {} message GetSystemFeaturesResponse { - reserved 4, 7; - reserved "oidc_legacy_introspection", "actions"; + reserved 3, 4, 7; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions"; zitadel.object.v2.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -108,13 +101,6 @@ message GetSystemFeaturesResponse { } ]; - FeatureFlag oidc_trigger_introspection_projections = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2beta/instance.proto b/proto/zitadel/feature/v2beta/instance.proto index 7968668e50..ac7a6c9286 100644 --- a/proto/zitadel/feature/v2beta/instance.proto +++ b/proto/zitadel/feature/v2beta/instance.proto @@ -11,20 +11,14 @@ import "zitadel/feature/v2beta/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; message SetInstanceFeaturesRequest{ - reserved 3, 6, 8; - reserved "oidc_legacy_introspection", "actions", "web_key"; + reserved 2, 3, 6, 8; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions", "web_key"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; description: "The login UI will use the settings of the default org (and not from the instance) if no organization context is set"; } ]; - optional bool oidc_trigger_introspection_projections = 2 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -84,8 +78,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { - reserved 4, 7, 9; - reserved "oidc_legacy_introspection", "actions", "web_key"; + reserved 3, 4, 7, 9; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions", "web_key"; zitadel.object.v2beta.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -94,13 +88,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag oidc_trigger_introspection_projections = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2beta/system.proto b/proto/zitadel/feature/v2beta/system.proto index 95bf71da9b..ae500eb87b 100644 --- a/proto/zitadel/feature/v2beta/system.proto +++ b/proto/zitadel/feature/v2beta/system.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2beta/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; message SetSystemFeaturesRequest{ - reserved 3, 6; - reserved "oidc_legacy_introspection", "actions"; + reserved 2, 3, 6; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -20,13 +20,6 @@ message SetSystemFeaturesRequest{ } ]; - optional bool oidc_trigger_introspection_projections = 2 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -71,8 +64,8 @@ message ResetSystemFeaturesResponse { message GetSystemFeaturesRequest {} message GetSystemFeaturesResponse { - reserved 4, 7; - reserved "oidc_legacy_introspection", "actions"; + reserved 3, 4, 7; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions"; zitadel.object.v2beta.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -81,13 +74,6 @@ message GetSystemFeaturesResponse { } ]; - FeatureFlag oidc_trigger_introspection_projections = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; From b7d447e313891d344fb55fc056c1a1a4654f57f4 Mon Sep 17 00:00:00 2001 From: Federico Coppede Date: Mon, 30 Jun 2025 09:21:08 -0300 Subject: [PATCH 13/66] docs(legal): Update account-lockout-policy.md (#10124) Review finished for the account lockout policy. Main changes: - Revised wording - Removed free account from the policy scope - Fixed broken link to the support form in the customer portal --------- Co-authored-by: Maximilian --- .../legal/policies/account-lockout-policy.md | 67 +++++++++++-------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/docs/docs/legal/policies/account-lockout-policy.md b/docs/docs/legal/policies/account-lockout-policy.md index a593eac1bc..663fd12d9d 100644 --- a/docs/docs/legal/policies/account-lockout-policy.md +++ b/docs/docs/legal/policies/account-lockout-policy.md @@ -4,56 +4,69 @@ sidebar_label: Account Lockout Policy custom_edit_url: null --- -Last updated on May 31, 2023 +Last updated on June 25, 2025 -This policy is an annex to the [Terms of Service](../terms-of-service) that clarifies your obligations and our procedure handling requests where you can't get access to your ZITADEL Cloud services and data. This policy is applicable to situations where we, ZITADEL, need to restore your access for a otherwise available service and not in cases where the services are unavailable. +This policy is an annex to the [Terms of Service](../terms-of-service) and outlines your responsibilities, as well as our procedures, for handling situations where you are unable to access your ZITADEL Cloud services or data. -## Why to do we have this policy? +It applies specifically to cases where **ZITADEL** must restore your access to services that are otherwise operational, and does **not** cover service outages or unavailability. -Users may not be able to access our services anymore due to loss of credentials or misconfiguration. -In certain circumstances it might not be possible to recover the credentials through a self-service flow (eg, loss of 2FA credentials) or access the system to undo the configuration that caused the issue. -These cases might require help from our support, so you can regain access to your data. -We will require some initial information and conditions to be able to assist you, and will require further information to handle the request. -We also keep the right to refuse any such request without providing a reason, in case you can't provide the requested information. +## Why do we have this policy? -## Scope +Users may lose access to ZITADEL services due to lost credentials or misconfiguration. -In scope of this policy are requests to recover +In some cases, it may not be possible to recover access through self-service options—for example, losing access to 2FA credentials or being unable to reverse a misconfiguration. These situations may require support from our team to help you regain access to your data. -- ZITADEL Cloud account (customer portal) -- Manager accounts to a specific instance -- Undo configuration changes resulting in lockout (eg, misconfigured Action) +To assist with such requests, we will require specific information and may request additional details throughout the process. -Out of scope are requests to recover access +**ZITADEL reserves the right to decline any access recovery request without providing a reason if the required information cannot be verified or provided.** + + +## Scope of This Policy + +This policy applies to the following situations: + +- Loss of access to your **ZITADEL Cloud Admin Account** (customer portal) +- Inability to access **Instance Manager accounts** for a specific instance +- Need to **undo configuration changes** that caused a lockout (e.g., a misconfigured Action) + + +## Out of Scope + +The following types of access recovery requests are **not** covered by this policy: + +- Situations where you can request access from another **Admin** or **Instance Manager** +- Requests made by **end-users** who should instead contact their Admin or Manager +- Issues related to **self-hosted ZITADEL instances** +- **Free accounts/Instances** -- Where you have to option to ask another Admin/Manager -- by end-users who should ask an Admin/Manager instead -- self-hosted instances ## Process -Before you send a request to restore access to your account, please make sure that can't ask your manager/admin or another manager/admin to recover access. +Before submitting a request to restore access to your account, please ensure that you are unable to regain access through your existing **Manager** or **Admin**, or by contacting another **Manager/Admin** within your organization. -### ZITADEL Cloud account -If you need to recover your ZITADEL Cloud account for the customer portal, please send an email to [support@zitadel.com](mailto:support@zitadel.com?subject=ZITADEL%20Cloud%20account%20lockout): +### ZITADEL Cloud account (Customer Portal) + +Please visit the [support page in the customer portal](https://zitadel.com/admin/support): - State clearly in the subject line that this is related to an account lockout for a ZITADEL Cloud account - The sender's email address must match the verified email address of the account owner - State the reason why you're not able to recover the account yourself -Please allow us time to validate your request. -Our support will get back to you to request additional information for verification. +Please allow us time to validate your request. +Our support team will follow up with additional verification steps if needed. -### Manager access to an Instance +### Instance Manager access recovery If you need to recover a Manager account to an instance, please make sure you can't recover the account via another user or service user with Manager permissions. -Please visit the [support page in the customer portal](https://zitadel.cloud/admin/support): +Please visit the [support page in the customer portal](https://zitadel.com/admin/support): -- State clearly in the subject line that this is related to an account lockout the affected instance +- State clearly in the subject line that this is related to an account lockout **for** the affected instance +- The sender's email address must match the verified email address of the affected instance manager - State the reason why you're not able to recover the account yourself -Please allow us time to validate your request. -Our support will get back to you to request additional information for verification. +Please allow us time to validate your request. +Our support team will follow up with additional verification steps if needed. + From 64a03fba28dc73e97f851c4d04cdb7e6596fc575 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Mon, 30 Jun 2025 11:07:33 -0400 Subject: [PATCH 14/66] fix(api): return typed saml form post data in idp intent (#10136) # Which Problems Are Solved The current user V2 API returns a `[]byte` containing a whole HTML document including the form on `StartIdentifyProviderIntent` for intents based on form post (e.g. SAML POST bindings). This is not usable for most clients as they cannot handle that and render a whole page inside their app. For redirect based intents, the url to which the client needs to redirect is returned. # How the Problems Are Solved - Changed the returned type to a new `FormData` message containing the url and a `fields` map. - internal changes: - Session.GetAuth now returns an `Auth` interfacce and error instead of (content string, redirect bool) - Auth interface has two implementations: `RedirectAuth` and `FormAuth` - All use of the GetAuth function now type switch on the returned auth object - A template has been added to the login UI to execute the form post automatically (as is). # Additional Changes - Some intent integration test did not check the redirect url and were wrongly configured. # Additional Context - relates to zitadel/typescript#410 --- .../user/v2/integration_test/user_test.go | 22 ++- internal/api/grpc/user/v2/intent.go | 27 ++-- .../user/v2beta/integration_test/user_test.go | 22 ++- internal/api/grpc/user/v2beta/user.go | 27 ++-- .../api/ui/login/external_provider_handler.go | 32 ++++- internal/command/idp_intent_test.go | 135 +++++++++++++++--- internal/idp/providers/apple/apple_test.go | 8 +- .../idp/providers/azuread/azuread_test.go | 8 +- internal/idp/providers/azuread/session.go | 2 +- internal/idp/providers/github/github_test.go | 8 +- internal/idp/providers/gitlab/gitlab_test.go | 8 +- internal/idp/providers/google/google_test.go | 8 +- internal/idp/providers/jwt/jwt_test.go | 8 +- internal/idp/providers/jwt/session.go | 2 +- internal/idp/providers/ldap/session.go | 2 +- internal/idp/providers/oauth/oauth2_test.go | 8 +- internal/idp/providers/oauth/session.go | 2 +- internal/idp/providers/oidc/oidc_test.go | 8 +- internal/idp/providers/oidc/session.go | 2 +- internal/idp/providers/saml/saml_test.go | 130 +++++++++++++++++ internal/idp/providers/saml/session.go | 93 +++++++----- internal/idp/session.go | 30 +++- proto/zitadel/user/v2/idp.proto | 18 +++ proto/zitadel/user/v2/user_service.proto | 4 + proto/zitadel/user/v2beta/idp.proto | 18 +++ proto/zitadel/user/v2beta/user_service.proto | 13 +- 26 files changed, 512 insertions(+), 133 deletions(-) diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index 4eee44ab44..1776c57fcb 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -2057,7 +2057,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - url: "http://" + Instance.Domain + ":8000/sso", + url: "http://localhost:8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -2081,7 +2081,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - url: "http://" + Instance.Domain + ":8000/sso", + url: "http://localhost:8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -2105,7 +2105,9 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - postForm: true, + url: "http://localhost:8000/sso", + parametersExisting: []string{"RelayState", "SAMLRequest"}, + postForm: true, }, wantErr: false, }, @@ -2143,9 +2145,11 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } require.NoError(t, err) - if tt.want.url != "" { + if tt.want.url != "" && !tt.want.postForm { authUrl, err := url.Parse(got.GetAuthUrl()) require.NoError(t, err) + + assert.Equal(t, tt.want.url, authUrl.Scheme+"://"+authUrl.Host+authUrl.Path) require.Len(t, authUrl.Query(), len(tt.want.parametersEqual)+len(tt.want.parametersExisting)) for _, existing := range tt.want.parametersExisting { @@ -2156,7 +2160,15 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } } if tt.want.postForm { - assert.NotEmpty(t, got.GetPostForm()) + assert.Equal(t, tt.want.url, got.GetFormData().GetUrl()) + + require.Len(t, got.GetFormData().GetFields(), len(tt.want.parametersEqual)+len(tt.want.parametersExisting)) + for _, existing := range tt.want.parametersExisting { + assert.Contains(t, got.GetFormData().GetFields(), existing) + } + for key, equal := range tt.want.parametersEqual { + assert.Equal(t, got.GetFormData().GetFields()[key], equal) + } } integration.AssertDetails(t, &user.StartIdentityProviderIntentResponse{ Details: tt.want.details, diff --git a/internal/api/grpc/user/v2/intent.go b/internal/api/grpc/user/v2/intent.go index 5514b6ef03..fd65d61dfb 100644 --- a/internal/api/grpc/user/v2/intent.go +++ b/internal/api/grpc/user/v2/intent.go @@ -52,19 +52,28 @@ func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.Re if err != nil { return nil, err } - content, redirect := session.GetAuth(ctx) - if redirect { + auth, err := session.GetAuth(ctx) + if err != nil { + return nil, err + } + switch a := auth.(type) { + case *idp.RedirectAuth: return &user.StartIdentityProviderIntentResponse{ Details: object.DomainToDetailsPb(details), - NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: content}, + NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: a.RedirectURL}, + }, nil + case *idp.FormAuth: + return &user.StartIdentityProviderIntentResponse{ + Details: object.DomainToDetailsPb(details), + NextStep: &user.StartIdentityProviderIntentResponse_FormData{ + FormData: &user.FormData{ + Url: a.URL, + Fields: a.Fields, + }, + }, }, nil } - return &user.StartIdentityProviderIntentResponse{ - Details: object.DomainToDetailsPb(details), - NextStep: &user.StartIdentityProviderIntentResponse_PostForm{ - PostForm: []byte(content), - }, - }, nil + return nil, zerrors.ThrowInvalidArgumentf(nil, "USERv2-3g2j3", "type oneOf %T in method StartIdentityProviderIntent not implemented", auth) } func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*user.StartIdentityProviderIntentResponse, error) { diff --git a/internal/api/grpc/user/v2beta/integration_test/user_test.go b/internal/api/grpc/user/v2beta/integration_test/user_test.go index 250322d66f..7b02f7da70 100644 --- a/internal/api/grpc/user/v2beta/integration_test/user_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/user_test.go @@ -2058,7 +2058,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - url: "http://" + Instance.Domain + ":8000/sso", + url: "http://localhost:8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -2082,7 +2082,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - url: "http://" + Instance.Domain + ":8000/sso", + url: "http://localhost:8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -2106,7 +2106,9 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - postForm: true, + url: "http://localhost:8000/sso", + parametersExisting: []string{"RelayState", "SAMLRequest"}, + postForm: true, }, wantErr: false, }, @@ -2120,9 +2122,11 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } require.NoError(t, err) - if tt.want.url != "" { + if tt.want.url != "" && !tt.want.postForm { authUrl, err := url.Parse(got.GetAuthUrl()) require.NoError(t, err) + + assert.Equal(t, tt.want.url, authUrl.Scheme+"://"+authUrl.Host+authUrl.Path) require.Len(t, authUrl.Query(), len(tt.want.parametersEqual)+len(tt.want.parametersExisting)) for _, existing := range tt.want.parametersExisting { @@ -2133,7 +2137,15 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } } if tt.want.postForm { - assert.NotEmpty(t, got.GetPostForm()) + assert.Equal(t, tt.want.url, got.GetFormData().GetUrl()) + + require.Len(t, got.GetFormData().GetFields(), len(tt.want.parametersEqual)+len(tt.want.parametersExisting)) + for _, existing := range tt.want.parametersExisting { + assert.Contains(t, got.GetFormData().GetFields(), existing) + } + for key, equal := range tt.want.parametersEqual { + assert.Equal(t, got.GetFormData().GetFields()[key], equal) + } } integration.AssertDetails(t, &user.StartIdentityProviderIntentResponse{ Details: tt.want.details, diff --git a/internal/api/grpc/user/v2beta/user.go b/internal/api/grpc/user/v2beta/user.go index 93afbde0aa..49f0c7d9c7 100644 --- a/internal/api/grpc/user/v2beta/user.go +++ b/internal/api/grpc/user/v2beta/user.go @@ -380,19 +380,28 @@ func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.Re if err != nil { return nil, err } - content, redirect := session.GetAuth(ctx) - if redirect { + auth, err := session.GetAuth(ctx) + if err != nil { + return nil, err + } + switch a := auth.(type) { + case *idp.RedirectAuth: return &user.StartIdentityProviderIntentResponse{ Details: object.DomainToDetailsPb(details), - NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: content}, + NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: a.RedirectURL}, + }, nil + case *idp.FormAuth: + return &user.StartIdentityProviderIntentResponse{ + Details: object.DomainToDetailsPb(details), + NextStep: &user.StartIdentityProviderIntentResponse_FormData{ + FormData: &user.FormData{ + Url: a.URL, + Fields: a.Fields, + }, + }, }, nil } - return &user.StartIdentityProviderIntentResponse{ - Details: object.DomainToDetailsPb(details), - NextStep: &user.StartIdentityProviderIntentResponse_PostForm{ - PostForm: []byte(content), - }, - }, nil + return nil, zerrors.ThrowInvalidArgumentf(nil, "USERv2-3g2j3", "type oneOf %T in method StartIdentityProviderIntent not implemented", auth) } func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*user.StartIdentityProviderIntentResponse, error) { diff --git a/internal/api/ui/login/external_provider_handler.go b/internal/api/ui/login/external_provider_handler.go index 6202c38c8b..abd20088ba 100644 --- a/internal/api/ui/login/external_provider_handler.go +++ b/internal/api/ui/login/external_provider_handler.go @@ -48,6 +48,18 @@ const ( tmplExternalNotFoundOption = "externalnotfoundoption" ) +var ( + samlFormPost = template.Must(template.New("saml-post-form").Parse(` +
+{{range $key, $value := .Fields}} + +{{end}} + +
+ +`)) +) + type externalIDPData struct { IDPConfigID string `schema:"idpConfigID"` } @@ -201,15 +213,21 @@ func (l *Login) handleIDP(w http.ResponseWriter, r *http.Request, authReq *domai l.externalAuthFailed(w, r, authReq, err) return } - - content, redirect := session.GetAuth(r.Context()) - if redirect { - http.Redirect(w, r, content, http.StatusFound) + auth, err := session.GetAuth(r.Context()) + if err != nil { + l.renderInternalError(w, r, authReq, err) return } - _, err = w.Write([]byte(content)) - if err != nil { - l.renderError(w, r, authReq, err) + switch a := auth.(type) { + case *idp.RedirectAuth: + http.Redirect(w, r, a.RedirectURL, http.StatusFound) + return + case *idp.FormAuth: + err = samlFormPost.Execute(w, a) + if err != nil { + l.renderError(w, r, authReq, err) + return + } return } } diff --git a/internal/command/idp_intent_test.go b/internal/command/idp_intent_test.go index 6cf835f521..e0f4e2ffdb 100644 --- a/internal/command/idp_intent_test.go +++ b/internal/command/idp_intent_test.go @@ -432,9 +432,8 @@ func TestCommands_AuthFromProvider(t *testing.T) { samlRootURL string } type res struct { - content string - redirect bool - err error + auth idp.Auth + err error } tests := []struct { name string @@ -579,8 +578,7 @@ func TestCommands_AuthFromProvider(t *testing.T) { callbackURL: "url", }, res{ - content: "auth?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&state=id", - redirect: true, + auth: &idp.RedirectAuth{RedirectURL: "auth?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&state=id"}, }, }, { @@ -671,8 +669,7 @@ func TestCommands_AuthFromProvider(t *testing.T) { callbackURL: "url", }, res{ - content: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&scope=openid+profile+User.Read&state=id", - redirect: true, + auth: &idp.RedirectAuth{RedirectURL: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&scope=openid+profile+User.Read&state=id"}, }, }, } @@ -686,13 +683,12 @@ func TestCommands_AuthFromProvider(t *testing.T) { _, session, err := c.AuthFromProvider(tt.args.ctx, tt.args.idpID, tt.args.callbackURL, tt.args.samlRootURL) require.ErrorIs(t, err, tt.res.err) - var content string - var redirect bool + var got idp.Auth if err == nil { - content, redirect = session.GetAuth(tt.args.ctx) + got, err = session.GetAuth(tt.args.ctx) + assert.Equal(t, tt.res.auth, got) + assert.NoError(t, err) } - assert.Equal(t, tt.res.redirect, redirect) - assert.Equal(t, tt.res.content, content) }) } } @@ -811,6 +807,97 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) { }, }, }, + { + "saml post auth", + fields{ + secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + eventstore: expectEventstore( + expectFilter( + eventFromEventPusherWithInstanceID( + "instance", + instance.NewSAMLIDPAddedEvent(context.Background(), &instance.NewAggregate("instance").Aggregate, + "idp", + "name", + []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("key"), + }, + []byte("certificate"), + "", + false, + gu.Ptr(domain.SAMLNameIDFormatUnspecified), + "", + false, + rep_idp.Options{}, + )), + ), + expectFilter( + eventFromEventPusherWithInstanceID( + "instance", + instance.NewSAMLIDPAddedEvent(context.Background(), &instance.NewAggregate("instance").Aggregate, + "idp", + "name", + []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAxHd087RoEm9ywVWZ/H+tDWxQsmVvhfRz4jAq/RfU+OWXNH4J\njMMSHdFs0Q+WP98nNXRyc7fgbMb8NdmlB2yD4qLYapN5SDaBc5dh/3EnyFt53oSs\njTlKnQUPAeJr2qh/NY046CfyUyQMM4JR5OiQFo4TssfWnqdcgamGt0AEnk2lvbMZ\nKQdAqNS9lDzYbjMGavEQPTZE35mFXFQXjaooZXq+TIa7hbaq7/idH7cHNbLcPLgj\nfPQA8q+DYvnvhXlmq0LPQZH3Oiixf+SF2vRwrBzT2mqGD2OiOkUmhuPwyqEiiBHt\nfxklRtRU6WfLa1Gcb1PsV0uoBGpV3KybIl/GlwIDAQABAoIBAEQjDduLgOCL6Gem\n0X3hpdnW6/HC/jed/Sa//9jBECq2LYeWAqff64ON40hqOHi0YvvGA/+gEOSI6mWe\nsv5tIxxRz+6+cLybsq+tG96kluCE4TJMHy/nY7orS/YiWbd+4odnEApr+D3fbZ/b\nnZ1fDsHTyn8hkYx6jLmnWsJpIHDp7zxD76y7k2Bbg6DZrCGiVxngiLJk23dvz79W\np03lHLM7XE92aFwXQmhfxHGxrbuoB/9eY4ai5IHp36H4fw0vL6NXdNQAo/bhe0p9\nAYB7y0ZumF8Hg0Z/BmMeEzLy6HrYB+VE8cO93pNjhSyH+p2yDB/BlUyTiRLQAoM0\nVTmOZXECgYEA7NGlzpKNhyQEJihVqt0MW0LhKIO/xbBn+XgYfX6GpqPa/ucnMx5/\nVezpl3gK8IU4wPUhAyXXAHJiqNBcEeyxrw0MXLujDVMJgYaLysCLJdvMVgoY08mS\nK5IQivpbozpf4+0y3mOnA+Sy1kbfxv2X8xiWLODRQW3f3q/xoklwOR8CgYEA1GEe\nfaibOFTQAYcIVj77KXtBfYZsX3EGAyfAN9O7cKHq5oaxVstwnF47WxpuVtoKZxCZ\nbNm9D5WvQ9b+Ztpioe42tzwE7Bff/Osj868GcDdRPK7nFlh9N2yVn/D514dOYVwR\n4MBr1KrJzgRWt4QqS4H+to1GzudDTSNlG7gnK4kCgYBUi6AbOHzoYzZL/RhgcJwp\ntJ23nhmH1Su5h2OO4e3mbhcP66w19sxU+8iFN+kH5zfUw26utgKk+TE5vXExQQRK\nT2k7bg2PAzcgk80ybD0BHhA8I0yrx4m0nmfjhe/TPVLgh10iwgbtP+eM0i6v1vc5\nZWyvxu9N4ZEL6lpkqr0y1wKBgG/NAIQd8jhhTW7Aav8cAJQBsqQl038avJOEpYe+\nCnpsgoAAf/K0/f8TDCQVceh+t+MxtdK7fO9rWOxZjWsPo8Si5mLnUaAHoX4/OpnZ\nlYYVWMqdOEFnK+O1Yb7k2GFBdV2DXlX2dc1qavntBsls5ecB89id3pyk2aUN8Pf6\npYQhAoGAMGtrHFely9wyaxI0RTCyfmJbWZHGVGkv6ELK8wneJjdjl82XOBUGCg5q\naRCrTZ3dPitKwrUa6ibJCIFCIziiriBmjDvTHzkMvoJEap2TVxYNDR6IfINVsQ57\nlOsiC4A2uGq4Lbfld+gjoplJ5GX6qXtTgZ6m7eo0y7U6zm2tkN0=\n-----END RSA PRIVATE KEY-----\n"), + }, []byte("-----BEGIN CERTIFICATE-----\nMIIC2zCCAcOgAwIBAgIIAy/jm1gAAdEwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UE\nChMHWklUQURFTDAeFw0yMzA4MzAwNzExMTVaFw0yNDA4MjkwNzExMTVaMBIxEDAO\nBgNVBAoTB1pJVEFERUwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDE\nd3TztGgSb3LBVZn8f60NbFCyZW+F9HPiMCr9F9T45Zc0fgmMwxId0WzRD5Y/3yc1\ndHJzt+Bsxvw12aUHbIPiothqk3lINoFzl2H/cSfIW3nehKyNOUqdBQ8B4mvaqH81\njTjoJ/JTJAwzglHk6JAWjhOyx9aep1yBqYa3QASeTaW9sxkpB0Co1L2UPNhuMwZq\n8RA9NkTfmYVcVBeNqihler5MhruFtqrv+J0ftwc1stw8uCN89ADyr4Ni+e+FeWar\nQs9Bkfc6KLF/5IXa9HCsHNPaaoYPY6I6RSaG4/DKoSKIEe1/GSVG1FTpZ8trUZxv\nU+xXS6gEalXcrJsiX8aXAgMBAAGjNTAzMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE\nDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCx\n/dRNIj0N/16zJhZR/ahkc2AkvDXYxyr4JRT5wK9GQDNl/oaX3debRuSi/tfaXFIX\naJA6PxM4J49ZaiEpLrKfxMz5kAhjKchCBEMcH3mGt+iNZH7EOyTvHjpGrP2OZrsh\nO17yrvN3HuQxIU6roJlqtZz2iAADsoPtwOO4D7hupm9XTMkSnAmlMWOo/q46Jz89\n1sMxB+dXmH/zV0wgwh0omZfLV0u89mvdq269VhcjNBpBYSnN1ccqYWd5iwziob3I\nvaavGHGfkbvRUn/tKftYuTK30q03R+e9YbmlWZ0v695owh2e/apCzowQsCKfSVC8\nOxVyt5XkHq1tWwVyBmFp\n-----END CERTIFICATE-----\n"), + "", + false, + gu.Ptr(domain.SAMLNameIDFormatUnspecified), + "", + false, + rep_idp.Options{}, + )), + ), + expectFilter( + eventFromEventPusherWithInstanceID( + "instance", + func() eventstore.Command { + success, _ := url.Parse("https://success.url") + failure, _ := url.Parse("https://failure.url") + return idpintent.NewStartedEvent( + context.Background(), + &idpintent.NewAggregate("id", "instance").Aggregate, + success, + failure, + "idp", + nil, + ) + }(), + ), + ), + expectRandomPush( + []eventstore.Command{ + idpintent.NewSAMLRequestEvent( + context.Background(), + &idpintent.NewAggregate("id", "instance").Aggregate, + "request", + ), + }, + ), + ), + idGenerator: mock.ExpectID(t, "id"), + }, + args{ + ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), + idpID: "idp", + callbackURL: "url", + samlRootURL: "samlurl", + }, + res{ + url: "http://localhost:8000/sso", + values: map[string]string{ + "SAMLRequest": "", // generated IDs so not assertable + "RelayState": "id", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -822,16 +909,30 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) { _, session, err := c.AuthFromProvider(tt.args.ctx, tt.args.idpID, tt.args.callbackURL, tt.args.samlRootURL) require.ErrorIs(t, err, tt.res.err) - content, _ := session.GetAuth(tt.args.ctx) - authURL, err := url.Parse(content) + auth, err := session.GetAuth(tt.args.ctx) require.NoError(t, err) + var authURL *url.URL + authFields := make(map[string]string) + + switch a := auth.(type) { + case *idp.RedirectAuth: + authURL, err = url.Parse(a.RedirectURL) + for key, values := range authURL.Query() { + authFields[key] = values[0] + } + require.NoError(t, err) + case *idp.FormAuth: + authURL, err = url.Parse(a.URL) + require.NoError(t, err) + authFields = a.Fields + } + assert.Equal(t, tt.res.url, authURL.Scheme+"://"+authURL.Host+authURL.Path) - query := authURL.Query() for k, v := range tt.res.values { - assert.True(t, query.Has(k)) + assert.Contains(t, authFields, k) if v != "" { - assert.Equal(t, v, query.Get(k)) + assert.Equal(t, v, authFields[k]) } } }) diff --git a/internal/idp/providers/apple/apple_test.go b/internal/idp/providers/apple/apple_test.go index f3b7e81a1a..7d1f3a8481 100644 --- a/internal/idp/providers/apple/apple_test.go +++ b/internal/idp/providers/apple/apple_test.go @@ -62,10 +62,10 @@ func TestProvider_BeginAuth(t *testing.T) { ctx := context.Background() session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - content, redirect := session.GetAuth(ctx) - contentExpected, redirectExpected := tt.want.GetAuth(ctx) - a.Equal(redirectExpected, redirect) - a.Equal(contentExpected, content) + auth, err := session.GetAuth(ctx) + authExpected, errExpected := tt.want.GetAuth(ctx) + a.ErrorIs(err, errExpected) + a.Equal(authExpected, auth) }) } } diff --git a/internal/idp/providers/azuread/azuread_test.go b/internal/idp/providers/azuread/azuread_test.go index 122a70bb07..e46815cc8e 100644 --- a/internal/idp/providers/azuread/azuread_test.go +++ b/internal/idp/providers/azuread/azuread_test.go @@ -81,10 +81,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/azuread/session.go b/internal/idp/providers/azuread/session.go index 169784fb58..f417897893 100644 --- a/internal/idp/providers/azuread/session.go +++ b/internal/idp/providers/azuread/session.go @@ -28,7 +28,7 @@ func NewSession(provider *Provider, code string) *Session { } // GetAuth implements the [idp.Provider] interface by calling the wrapped [oauth.Session]. -func (s *Session) GetAuth(ctx context.Context) (content string, redirect bool) { +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { return s.oauth().GetAuth(ctx) } diff --git a/internal/idp/providers/github/github_test.go b/internal/idp/providers/github/github_test.go index 6274b51841..42f03c050d 100644 --- a/internal/idp/providers/github/github_test.go +++ b/internal/idp/providers/github/github_test.go @@ -48,10 +48,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/gitlab/gitlab_test.go b/internal/idp/providers/gitlab/gitlab_test.go index 24b813bc81..99b28c5003 100644 --- a/internal/idp/providers/gitlab/gitlab_test.go +++ b/internal/idp/providers/gitlab/gitlab_test.go @@ -59,10 +59,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/google/google_test.go b/internal/idp/providers/google/google_test.go index b95f8eaf9f..b8f31b86e3 100644 --- a/internal/idp/providers/google/google_test.go +++ b/internal/idp/providers/google/google_test.go @@ -48,10 +48,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/jwt/jwt_test.go b/internal/idp/providers/jwt/jwt_test.go index 5756c58e07..aba337d2ee 100644 --- a/internal/idp/providers/jwt/jwt_test.go +++ b/internal/idp/providers/jwt/jwt_test.go @@ -119,10 +119,10 @@ func TestProvider_BeginAuth(t *testing.T) { } if tt.want.err == nil { a.NoError(err) - wantHeaders, wantContent := tt.want.session.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.session.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) } }) } diff --git a/internal/idp/providers/jwt/session.go b/internal/idp/providers/jwt/session.go index 85b164a9c5..0d91986fc9 100644 --- a/internal/idp/providers/jwt/session.go +++ b/internal/idp/providers/jwt/session.go @@ -42,7 +42,7 @@ func NewSessionFromRequest(provider *Provider, r *http.Request) *Session { } // GetAuth implements the [idp.Session] interface. -func (s *Session) GetAuth(ctx context.Context) (string, bool) { +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { return idp.Redirect(s.AuthURL) } diff --git a/internal/idp/providers/ldap/session.go b/internal/idp/providers/ldap/session.go index a78dd02d73..6a56cd6132 100644 --- a/internal/idp/providers/ldap/session.go +++ b/internal/idp/providers/ldap/session.go @@ -39,7 +39,7 @@ func NewSession(provider *Provider, username, password string) *Session { } // GetAuth implements the [idp.Session] interface. -func (s *Session) GetAuth(ctx context.Context) (string, bool) { +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { return idp.Redirect(s.loginUrl) } diff --git a/internal/idp/providers/oauth/oauth2_test.go b/internal/idp/providers/oauth/oauth2_test.go index 984315ac1f..93a0dd404f 100644 --- a/internal/idp/providers/oauth/oauth2_test.go +++ b/internal/idp/providers/oauth/oauth2_test.go @@ -80,10 +80,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/oauth/session.go b/internal/idp/providers/oauth/session.go index c9e175d1cf..27d38b1740 100644 --- a/internal/idp/providers/oauth/session.go +++ b/internal/idp/providers/oauth/session.go @@ -37,7 +37,7 @@ func NewSession(provider *Provider, code string, idpArguments map[string]any) *S } // GetAuth implements the [idp.Session] interface. -func (s *Session) GetAuth(ctx context.Context) (string, bool) { +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { return idp.Redirect(s.AuthURL) } diff --git a/internal/idp/providers/oidc/oidc_test.go b/internal/idp/providers/oidc/oidc_test.go index a46f09f13f..86e23f95d2 100644 --- a/internal/idp/providers/oidc/oidc_test.go +++ b/internal/idp/providers/oidc/oidc_test.go @@ -98,10 +98,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/oidc/session.go b/internal/idp/providers/oidc/session.go index 9e1e55baf5..08e277a9cc 100644 --- a/internal/idp/providers/oidc/session.go +++ b/internal/idp/providers/oidc/session.go @@ -33,7 +33,7 @@ func NewSession(provider *Provider, code string, idpArguments map[string]any) *S } // GetAuth implements the [idp.Session] interface. -func (s *Session) GetAuth(ctx context.Context) (string, bool) { +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { return idp.Redirect(s.AuthURL) } diff --git a/internal/idp/providers/saml/saml_test.go b/internal/idp/providers/saml/saml_test.go index 69ff231ccc..5e76e6dcaa 100644 --- a/internal/idp/providers/saml/saml_test.go +++ b/internal/idp/providers/saml/saml_test.go @@ -1,7 +1,9 @@ package saml import ( + "context" "encoding/xml" + "net/url" "testing" "time" @@ -11,10 +13,138 @@ import ( "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/idp" "github.com/zitadel/zitadel/internal/idp/providers/saml/requesttracker" "github.com/zitadel/zitadel/internal/zerrors" ) +func TestProvider_BeginAuth(t *testing.T) { + requestTracker := requesttracker.New( + func(ctx context.Context, authRequestID, samlRequestID string) error { + assert.Equal(t, "state", authRequestID) + return nil + }, + func(ctx context.Context, authRequestID string) (*samlsp.TrackedRequest, error) { + return &samlsp.TrackedRequest{ + SAMLRequestID: "state", + Index: authRequestID, + }, nil + }, + ) + type fields struct { + name string + rootURL string + metadata []byte + certificate []byte + key []byte + options []ProviderOpts + } + type args struct { + state string + } + type want struct { + err func(error) bool + authType idp.Auth + ssoURL string + relayState string + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + name: "redirect binding, success", + fields: fields{ + name: "saml", + rootURL: "https://localhost:8080", + metadata: []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), + certificate: []byte("-----BEGIN CERTIFICATE-----\nMIIC2zCCAcOgAwIBAgIIAy/jm1gAAdEwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UE\nChMHWklUQURFTDAeFw0yMzA4MzAwNzExMTVaFw0yNDA4MjkwNzExMTVaMBIxEDAO\nBgNVBAoTB1pJVEFERUwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDE\nd3TztGgSb3LBVZn8f60NbFCyZW+F9HPiMCr9F9T45Zc0fgmMwxId0WzRD5Y/3yc1\ndHJzt+Bsxvw12aUHbIPiothqk3lINoFzl2H/cSfIW3nehKyNOUqdBQ8B4mvaqH81\njTjoJ/JTJAwzglHk6JAWjhOyx9aep1yBqYa3QASeTaW9sxkpB0Co1L2UPNhuMwZq\n8RA9NkTfmYVcVBeNqihler5MhruFtqrv+J0ftwc1stw8uCN89ADyr4Ni+e+FeWar\nQs9Bkfc6KLF/5IXa9HCsHNPaaoYPY6I6RSaG4/DKoSKIEe1/GSVG1FTpZ8trUZxv\nU+xXS6gEalXcrJsiX8aXAgMBAAGjNTAzMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE\nDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCx\n/dRNIj0N/16zJhZR/ahkc2AkvDXYxyr4JRT5wK9GQDNl/oaX3debRuSi/tfaXFIX\naJA6PxM4J49ZaiEpLrKfxMz5kAhjKchCBEMcH3mGt+iNZH7EOyTvHjpGrP2OZrsh\nO17yrvN3HuQxIU6roJlqtZz2iAADsoPtwOO4D7hupm9XTMkSnAmlMWOo/q46Jz89\n1sMxB+dXmH/zV0wgwh0omZfLV0u89mvdq269VhcjNBpBYSnN1ccqYWd5iwziob3I\nvaavGHGfkbvRUn/tKftYuTK30q03R+e9YbmlWZ0v695owh2e/apCzowQsCKfSVC8\nOxVyt5XkHq1tWwVyBmFp\n-----END CERTIFICATE-----\n"), + key: []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAxHd087RoEm9ywVWZ/H+tDWxQsmVvhfRz4jAq/RfU+OWXNH4J\njMMSHdFs0Q+WP98nNXRyc7fgbMb8NdmlB2yD4qLYapN5SDaBc5dh/3EnyFt53oSs\njTlKnQUPAeJr2qh/NY046CfyUyQMM4JR5OiQFo4TssfWnqdcgamGt0AEnk2lvbMZ\nKQdAqNS9lDzYbjMGavEQPTZE35mFXFQXjaooZXq+TIa7hbaq7/idH7cHNbLcPLgj\nfPQA8q+DYvnvhXlmq0LPQZH3Oiixf+SF2vRwrBzT2mqGD2OiOkUmhuPwyqEiiBHt\nfxklRtRU6WfLa1Gcb1PsV0uoBGpV3KybIl/GlwIDAQABAoIBAEQjDduLgOCL6Gem\n0X3hpdnW6/HC/jed/Sa//9jBECq2LYeWAqff64ON40hqOHi0YvvGA/+gEOSI6mWe\nsv5tIxxRz+6+cLybsq+tG96kluCE4TJMHy/nY7orS/YiWbd+4odnEApr+D3fbZ/b\nnZ1fDsHTyn8hkYx6jLmnWsJpIHDp7zxD76y7k2Bbg6DZrCGiVxngiLJk23dvz79W\np03lHLM7XE92aFwXQmhfxHGxrbuoB/9eY4ai5IHp36H4fw0vL6NXdNQAo/bhe0p9\nAYB7y0ZumF8Hg0Z/BmMeEzLy6HrYB+VE8cO93pNjhSyH+p2yDB/BlUyTiRLQAoM0\nVTmOZXECgYEA7NGlzpKNhyQEJihVqt0MW0LhKIO/xbBn+XgYfX6GpqPa/ucnMx5/\nVezpl3gK8IU4wPUhAyXXAHJiqNBcEeyxrw0MXLujDVMJgYaLysCLJdvMVgoY08mS\nK5IQivpbozpf4+0y3mOnA+Sy1kbfxv2X8xiWLODRQW3f3q/xoklwOR8CgYEA1GEe\nfaibOFTQAYcIVj77KXtBfYZsX3EGAyfAN9O7cKHq5oaxVstwnF47WxpuVtoKZxCZ\nbNm9D5WvQ9b+Ztpioe42tzwE7Bff/Osj868GcDdRPK7nFlh9N2yVn/D514dOYVwR\n4MBr1KrJzgRWt4QqS4H+to1GzudDTSNlG7gnK4kCgYBUi6AbOHzoYzZL/RhgcJwp\ntJ23nhmH1Su5h2OO4e3mbhcP66w19sxU+8iFN+kH5zfUw26utgKk+TE5vXExQQRK\nT2k7bg2PAzcgk80ybD0BHhA8I0yrx4m0nmfjhe/TPVLgh10iwgbtP+eM0i6v1vc5\nZWyvxu9N4ZEL6lpkqr0y1wKBgG/NAIQd8jhhTW7Aav8cAJQBsqQl038avJOEpYe+\nCnpsgoAAf/K0/f8TDCQVceh+t+MxtdK7fO9rWOxZjWsPo8Si5mLnUaAHoX4/OpnZ\nlYYVWMqdOEFnK+O1Yb7k2GFBdV2DXlX2dc1qavntBsls5ecB89id3pyk2aUN8Pf6\npYQhAoGAMGtrHFely9wyaxI0RTCyfmJbWZHGVGkv6ELK8wneJjdjl82XOBUGCg5q\naRCrTZ3dPitKwrUa6ibJCIFCIziiriBmjDvTHzkMvoJEap2TVxYNDR6IfINVsQ57\nlOsiC4A2uGq4Lbfld+gjoplJ5GX6qXtTgZ6m7eo0y7U6zm2tkN0=\n-----END RSA PRIVATE KEY-----\n"), + options: []ProviderOpts{ + WithCustomRequestTracker(requestTracker), + }, + }, + args: args{ + state: "state", + }, + want: want{ + authType: &idp.RedirectAuth{}, + ssoURL: "http://localhost:8000/sso", + relayState: "state", + }, + }, + { + name: "post binding, success", + fields: fields{ + name: "saml", + rootURL: "https://localhost:8080", + metadata: []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), + certificate: []byte("-----BEGIN CERTIFICATE-----\nMIIC2zCCAcOgAwIBAgIIAy/jm1gAAdEwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UE\nChMHWklUQURFTDAeFw0yMzA4MzAwNzExMTVaFw0yNDA4MjkwNzExMTVaMBIxEDAO\nBgNVBAoTB1pJVEFERUwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDE\nd3TztGgSb3LBVZn8f60NbFCyZW+F9HPiMCr9F9T45Zc0fgmMwxId0WzRD5Y/3yc1\ndHJzt+Bsxvw12aUHbIPiothqk3lINoFzl2H/cSfIW3nehKyNOUqdBQ8B4mvaqH81\njTjoJ/JTJAwzglHk6JAWjhOyx9aep1yBqYa3QASeTaW9sxkpB0Co1L2UPNhuMwZq\n8RA9NkTfmYVcVBeNqihler5MhruFtqrv+J0ftwc1stw8uCN89ADyr4Ni+e+FeWar\nQs9Bkfc6KLF/5IXa9HCsHNPaaoYPY6I6RSaG4/DKoSKIEe1/GSVG1FTpZ8trUZxv\nU+xXS6gEalXcrJsiX8aXAgMBAAGjNTAzMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE\nDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCx\n/dRNIj0N/16zJhZR/ahkc2AkvDXYxyr4JRT5wK9GQDNl/oaX3debRuSi/tfaXFIX\naJA6PxM4J49ZaiEpLrKfxMz5kAhjKchCBEMcH3mGt+iNZH7EOyTvHjpGrP2OZrsh\nO17yrvN3HuQxIU6roJlqtZz2iAADsoPtwOO4D7hupm9XTMkSnAmlMWOo/q46Jz89\n1sMxB+dXmH/zV0wgwh0omZfLV0u89mvdq269VhcjNBpBYSnN1ccqYWd5iwziob3I\nvaavGHGfkbvRUn/tKftYuTK30q03R+e9YbmlWZ0v695owh2e/apCzowQsCKfSVC8\nOxVyt5XkHq1tWwVyBmFp\n-----END CERTIFICATE-----\n"), + key: []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAxHd087RoEm9ywVWZ/H+tDWxQsmVvhfRz4jAq/RfU+OWXNH4J\njMMSHdFs0Q+WP98nNXRyc7fgbMb8NdmlB2yD4qLYapN5SDaBc5dh/3EnyFt53oSs\njTlKnQUPAeJr2qh/NY046CfyUyQMM4JR5OiQFo4TssfWnqdcgamGt0AEnk2lvbMZ\nKQdAqNS9lDzYbjMGavEQPTZE35mFXFQXjaooZXq+TIa7hbaq7/idH7cHNbLcPLgj\nfPQA8q+DYvnvhXlmq0LPQZH3Oiixf+SF2vRwrBzT2mqGD2OiOkUmhuPwyqEiiBHt\nfxklRtRU6WfLa1Gcb1PsV0uoBGpV3KybIl/GlwIDAQABAoIBAEQjDduLgOCL6Gem\n0X3hpdnW6/HC/jed/Sa//9jBECq2LYeWAqff64ON40hqOHi0YvvGA/+gEOSI6mWe\nsv5tIxxRz+6+cLybsq+tG96kluCE4TJMHy/nY7orS/YiWbd+4odnEApr+D3fbZ/b\nnZ1fDsHTyn8hkYx6jLmnWsJpIHDp7zxD76y7k2Bbg6DZrCGiVxngiLJk23dvz79W\np03lHLM7XE92aFwXQmhfxHGxrbuoB/9eY4ai5IHp36H4fw0vL6NXdNQAo/bhe0p9\nAYB7y0ZumF8Hg0Z/BmMeEzLy6HrYB+VE8cO93pNjhSyH+p2yDB/BlUyTiRLQAoM0\nVTmOZXECgYEA7NGlzpKNhyQEJihVqt0MW0LhKIO/xbBn+XgYfX6GpqPa/ucnMx5/\nVezpl3gK8IU4wPUhAyXXAHJiqNBcEeyxrw0MXLujDVMJgYaLysCLJdvMVgoY08mS\nK5IQivpbozpf4+0y3mOnA+Sy1kbfxv2X8xiWLODRQW3f3q/xoklwOR8CgYEA1GEe\nfaibOFTQAYcIVj77KXtBfYZsX3EGAyfAN9O7cKHq5oaxVstwnF47WxpuVtoKZxCZ\nbNm9D5WvQ9b+Ztpioe42tzwE7Bff/Osj868GcDdRPK7nFlh9N2yVn/D514dOYVwR\n4MBr1KrJzgRWt4QqS4H+to1GzudDTSNlG7gnK4kCgYBUi6AbOHzoYzZL/RhgcJwp\ntJ23nhmH1Su5h2OO4e3mbhcP66w19sxU+8iFN+kH5zfUw26utgKk+TE5vXExQQRK\nT2k7bg2PAzcgk80ybD0BHhA8I0yrx4m0nmfjhe/TPVLgh10iwgbtP+eM0i6v1vc5\nZWyvxu9N4ZEL6lpkqr0y1wKBgG/NAIQd8jhhTW7Aav8cAJQBsqQl038avJOEpYe+\nCnpsgoAAf/K0/f8TDCQVceh+t+MxtdK7fO9rWOxZjWsPo8Si5mLnUaAHoX4/OpnZ\nlYYVWMqdOEFnK+O1Yb7k2GFBdV2DXlX2dc1qavntBsls5ecB89id3pyk2aUN8Pf6\npYQhAoGAMGtrHFely9wyaxI0RTCyfmJbWZHGVGkv6ELK8wneJjdjl82XOBUGCg5q\naRCrTZ3dPitKwrUa6ibJCIFCIziiriBmjDvTHzkMvoJEap2TVxYNDR6IfINVsQ57\nlOsiC4A2uGq4Lbfld+gjoplJ5GX6qXtTgZ6m7eo0y7U6zm2tkN0=\n-----END RSA PRIVATE KEY-----\n"), + options: []ProviderOpts{ + WithCustomRequestTracker(requestTracker), + }, + }, + args: args{ + state: "state", + }, + want: want{ + authType: &idp.FormAuth{}, + ssoURL: "http://localhost:8000/sso", + relayState: "state", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + + provider, err := New( + tt.fields.name, + tt.fields.rootURL, + tt.fields.metadata, + tt.fields.certificate, + tt.fields.key, + tt.fields.options..., + ) + require.NoError(t, err) + + ctx := context.Background() + session, err := provider.BeginAuth(ctx, tt.args.state, nil) + if tt.want.err != nil && !tt.want.err(err) { + a.Fail("invalid error", err) + } + if tt.want.err == nil { + a.NoError(err) + gotAuth, gotErr := session.GetAuth(ctx) + a.NoError(gotErr) + a.IsType(tt.want.authType, gotAuth) + + var ssoURL, relayState, samlRequest string + switch auth := gotAuth.(type) { + case *idp.RedirectAuth: + gotRedirect, err := url.Parse(auth.RedirectURL) + a.NoError(err) + gotQuery := gotRedirect.Query() + + ssoURL = gotRedirect.Scheme + "://" + gotRedirect.Host + gotRedirect.Path + relayState = gotQuery.Get("RelayState") + samlRequest = gotQuery.Get("SAMLRequest") + case *idp.FormAuth: + ssoURL = auth.URL + relayState = auth.Fields["RelayState"] + samlRequest = auth.Fields["SAMLRequest"] + } + a.Equal(tt.want.ssoURL, ssoURL) + a.Equal(tt.want.relayState, relayState) + a.NotEmpty(samlRequest) + } + }) + } +} + func TestProvider_Options(t *testing.T) { type fields struct { name string diff --git a/internal/idp/providers/saml/session.go b/internal/idp/providers/saml/session.go index e2a1655a26..e1f32209b0 100644 --- a/internal/idp/providers/saml/session.go +++ b/internal/idp/providers/saml/session.go @@ -1,13 +1,14 @@ package saml import ( - "bytes" "context" + "encoding/base64" "errors" "net/http" "net/url" "time" + "github.com/beevik/etree" "github.com/crewjam/saml" "github.com/crewjam/saml/samlsp" @@ -43,22 +44,15 @@ func NewSession(provider *Provider, requestID string, request *http.Request) (*S } // GetAuth implements the [idp.Session] interface. -func (s *Session) GetAuth(ctx context.Context) (string, bool) { - url, _ := url.Parse(s.state) - resp := NewTempResponseWriter() - +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { + url, err := url.Parse(s.state) + if err != nil { + return nil, err + } request := &http.Request{ URL: url, } - s.ServiceProvider.HandleStartAuthFlow( - resp, - request.WithContext(ctx), - ) - - if location := resp.Header().Get("Location"); location != "" { - return idp.Redirect(location) - } - return idp.Form(resp.content.String()) + return s.auth(request.WithContext(ctx)) } // PersistentParameters implements the [idp.Session] interface. @@ -130,24 +124,57 @@ func (s *Session) transientMappingID() (string, error) { return "", zerrors.ThrowInvalidArgument(nil, "SAML-swwg2", "Errors.Intent.MissingSingleMappingAttribute") } -type TempResponseWriter struct { - header http.Header - content *bytes.Buffer -} - -func (w *TempResponseWriter) Header() http.Header { - return w.header -} - -func (w *TempResponseWriter) Write(content []byte) (int, error) { - return w.content.Write(content) -} - -func (w *TempResponseWriter) WriteHeader(statusCode int) {} - -func NewTempResponseWriter() *TempResponseWriter { - return &TempResponseWriter{ - header: map[string][]string{}, - content: bytes.NewBuffer([]byte{}), +// auth is a modified copy of the [samlsp.Middleware.HandleStartAuthFlow] method. +// Instead of writing the response to the http.ResponseWriter, it returns the auth request as an [idp.Auth]. +// In case of an error, it returns the error directly and does not write to the response. +func (s *Session) auth(r *http.Request) (idp.Auth, error) { + if r.URL.Path == s.ServiceProvider.ServiceProvider.AcsURL.Path { + // should never occur, but was handled in the original method, so we keep it here + return nil, zerrors.ThrowInvalidArgument(nil, "SAML-Eoi24", "don't wrap Middleware with RequireAccount") } + + var binding, bindingLocation string + if s.ServiceProvider.Binding != "" { + binding = s.ServiceProvider.Binding + bindingLocation = s.ServiceProvider.ServiceProvider.GetSSOBindingLocation(binding) + } else { + binding = saml.HTTPRedirectBinding + bindingLocation = s.ServiceProvider.ServiceProvider.GetSSOBindingLocation(binding) + if bindingLocation == "" { + binding = saml.HTTPPostBinding + bindingLocation = s.ServiceProvider.ServiceProvider.GetSSOBindingLocation(binding) + } + } + + authReq, err := s.ServiceProvider.ServiceProvider.MakeAuthenticationRequest(bindingLocation, binding, s.ServiceProvider.ResponseBinding) + if err != nil { + return nil, err + } + relayState, err := s.ServiceProvider.RequestTracker.TrackRequest(nil, r, authReq.ID) + if err != nil { + return nil, err + } + + if binding == saml.HTTPRedirectBinding { + redirectURL, err := authReq.Redirect(relayState, &s.ServiceProvider.ServiceProvider) + if err != nil { + return nil, err + } + return idp.Redirect(redirectURL.String()) + } + if binding == saml.HTTPPostBinding { + doc := etree.NewDocument() + doc.SetRoot(authReq.Element()) + reqBuf, err := doc.WriteToBytes() + if err != nil { + return nil, err + } + encodedReqBuf := base64.StdEncoding.EncodeToString(reqBuf) + return idp.Form(authReq.Destination, + map[string]string{ + "SAMLRequest": encodedReqBuf, + "RelayState": relayState, + }) + } + return nil, zerrors.ThrowInvalidArgument(nil, "SAML-Eoi24", "Errors.Intent.Invalid") } diff --git a/internal/idp/session.go b/internal/idp/session.go index fc593eb820..d0df3415bf 100644 --- a/internal/idp/session.go +++ b/internal/idp/session.go @@ -7,12 +7,29 @@ import ( // Session is the minimal implementation for a session of a 3rd party authentication [Provider] type Session interface { - GetAuth(ctx context.Context) (content string, redirect bool) + GetAuth(ctx context.Context) (Auth, error) PersistentParameters() map[string]any FetchUser(ctx context.Context) (User, error) ExpiresAt() time.Time } +type Auth interface { + auth() +} + +type RedirectAuth struct { + RedirectURL string +} + +func (r *RedirectAuth) auth() {} + +type FormAuth struct { + URL string + Fields map[string]string +} + +func (f *FormAuth) auth() {} + // SessionSupportsMigration is an optional extension to the Session interface. // It can be implemented to support migrating users, were the initial external id has changed because of a migration of the Provider type. // E.g. when a user was linked on a generic OIDC provider and this provider has now been migrated to an AzureAD provider. @@ -22,10 +39,13 @@ type SessionSupportsMigration interface { RetrievePreviousID() (previousID string, err error) } -func Redirect(redirectURL string) (string, bool) { - return redirectURL, true +func Redirect(redirectURL string) (*RedirectAuth, error) { + return &RedirectAuth{RedirectURL: redirectURL}, nil } -func Form(html string) (string, bool) { - return html, false +func Form(url string, fields map[string]string) (*FormAuth, error) { + return &FormAuth{ + URL: url, + Fields: fields, + }, nil } diff --git a/proto/zitadel/user/v2/idp.proto b/proto/zitadel/user/v2/idp.proto index 73e633fb67..828a035c29 100644 --- a/proto/zitadel/user/v2/idp.proto +++ b/proto/zitadel/user/v2/idp.proto @@ -162,3 +162,21 @@ message IDPLink { } ]; } + +message FormData { + // The URL to which the form should be submitted using the POST method. + string url = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"https://idp.com/saml/v2/acs\""; + } + ]; + // The form fields to be submitted. + // Each field is represented as a key-value pair, where the key is the field / input name + // and the value is the field / input value. + // All fields need to be submitted as is and as input type "text". + map fields = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "{\"relayState\":\"state\",\"SAMLRequest\":\"asjfkj3ir2fj248=\"}"; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 79f66266bc..349f3c6c54 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -2895,11 +2895,15 @@ message StartIdentityProviderIntentResponse{ description: "IDP Intent information" } ]; + // POST call information + // Deprecated: Use form_data instead bytes post_form = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { description: "POST call information" } ]; + // Data for a form POST call + FormData form_data = 5; } } diff --git a/proto/zitadel/user/v2beta/idp.proto b/proto/zitadel/user/v2beta/idp.proto index 7d58ec5363..237c8de114 100644 --- a/proto/zitadel/user/v2beta/idp.proto +++ b/proto/zitadel/user/v2beta/idp.proto @@ -162,3 +162,21 @@ message IDPLink { } ]; } + +message FormData { + // The URL to which the form should be submitted using the POST method. + string url = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"https://idp.com/saml/v2/acs\""; + } + ]; + // The form fields to be submitted. + // Each field is represented as a key-value pair, where the key is the field / input name + // and the value is the field / input value. + // All fields need to be submitted as is and as input type "text". + map fields = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "{\"relayState\":\"state\",\"SAMLRequest\":\"asjfkj3ir2fj248=\"}"; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/user/v2beta/user_service.proto b/proto/zitadel/user/v2beta/user_service.proto index f877252f51..bcb091abf2 100644 --- a/proto/zitadel/user/v2beta/user_service.proto +++ b/proto/zitadel/user/v2beta/user_service.proto @@ -1788,22 +1788,23 @@ message StartIdentityProviderIntentRequest{ message StartIdentityProviderIntentResponse{ zitadel.object.v2beta.Details details = 1; oneof next_step { + // URL to which the client should redirect string auth_url = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "URL to which the client should redirect" example: "\"https://accounts.google.com/o/oauth2/v2/auth?client_id=clientID&callback=https%3A%2F%2Fzitadel.cloud%2Fidps%2Fcallback\""; } ]; - IDPIntent idp_intent = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "IDP Intent information" - } - ]; + // IDP Intent information + IDPIntent idp_intent = 3; + // POST call information + // Deprecated: Use form_data instead bytes post_form = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { description: "POST call information" } ]; + // Data for a form POST call + FormData form_data = 5; } } From fce9e770ac9d24544fcbe3e842ff85c91e9d3d5f Mon Sep 17 00:00:00 2001 From: "Marco A." Date: Wed, 2 Jul 2025 09:34:19 +0200 Subject: [PATCH 15/66] feat: App Keys API v2 (#10140) # Which Problems Are Solved This PR *partially* addresses #9450 . Specifically, it implements the resource based API for app keys. This PR, together with https://github.com/zitadel/zitadel/pull/10077 completes #9450 . # How the Problems Are Solved - Implementation of the following endpoints: `CreateApplicationKey`, `DeleteApplicationKey`, `GetApplicationKey`, `ListApplicationKeys` - `ListApplicationKeys` can filter by project, app or organization ID. Sorting is also possible according to some criteria. - All endpoints use permissions V2 # TODO - [x] Deprecate old endpoints # Additional Context Closes #9450 --- internal/api/grpc/app/v2beta/app_key.go | 47 ++++ .../api/grpc/app/v2beta/convert/api_app.go | 38 +++ .../grpc/app/v2beta/convert/api_app_test.go | 69 +++++ .../api/grpc/app/v2beta/convert/convert.go | 97 +++++++ .../grpc/app/v2beta/convert/convert_test.go | 184 +++++++++++++ .../v2beta/integration_test/app_key_test.go | 206 ++++++++++++++ .../app/v2beta/integration_test/app_test.go | 32 +-- .../app/v2beta/integration_test/query_test.go | 251 +++++++++++++++++- .../v2beta/integration_test/server_test.go | 25 +- internal/api/grpc/app/v2beta/query.go | 39 +++ .../project_application_converter.go | 4 +- internal/command/project_application_key.go | 13 + .../command/project_application_key_test.go | 86 ++++-- internal/domain/mock/permission.go | 22 ++ internal/query/authn_key.go | 16 ++ internal/query/authn_key_test.go | 8 + proto/zitadel/app/v2beta/app.proto | 27 ++ proto/zitadel/app/v2beta/app_service.proto | 246 +++++++++++++++-- proto/zitadel/management.proto | 9 + 19 files changed, 1350 insertions(+), 69 deletions(-) create mode 100644 internal/api/grpc/app/v2beta/app_key.go create mode 100644 internal/api/grpc/app/v2beta/integration_test/app_key_test.go create mode 100644 internal/domain/mock/permission.go diff --git a/internal/api/grpc/app/v2beta/app_key.go b/internal/api/grpc/app/v2beta/app_key.go new file mode 100644 index 0000000000..8c0c1989b2 --- /dev/null +++ b/internal/api/grpc/app/v2beta/app_key.go @@ -0,0 +1,47 @@ +package app + +import ( + "context" + "strings" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func (s *Server) CreateApplicationKey(ctx context.Context, req *app.CreateApplicationKeyRequest) (*app.CreateApplicationKeyResponse, error) { + domainReq := convert.CreateAPIClientKeyRequestToDomain(req) + + appKey, err := s.command.AddApplicationKey(ctx, domainReq, "") + if err != nil { + return nil, err + } + + keyDetails, err := appKey.Detail() + if err != nil { + return nil, err + } + + return &app.CreateApplicationKeyResponse{ + Id: appKey.KeyID, + CreationDate: timestamppb.New(appKey.ChangeDate), + KeyDetails: keyDetails, + }, nil +} + +func (s *Server) DeleteApplicationKey(ctx context.Context, req *app.DeleteApplicationKeyRequest) (*app.DeleteApplicationKeyResponse, error) { + deletionDetails, err := s.command.RemoveApplicationKey(ctx, + strings.TrimSpace(req.GetProjectId()), + strings.TrimSpace(req.GetApplicationId()), + strings.TrimSpace(req.GetId()), + strings.TrimSpace(req.GetOrganizationId()), + ) + if err != nil { + return nil, err + } + + return &app.DeleteApplicationKeyResponse{ + DeletionDate: timestamppb.New(deletionDetails.EventDate), + }, nil +} diff --git a/internal/api/grpc/app/v2beta/convert/api_app.go b/internal/api/grpc/app/v2beta/convert/api_app.go index bad76ab0d5..4900d534cb 100644 --- a/internal/api/grpc/app/v2beta/convert/api_app.go +++ b/internal/api/grpc/app/v2beta/convert/api_app.go @@ -1,6 +1,8 @@ package convert import ( + "strings" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/query" @@ -58,3 +60,39 @@ func apiAuthMethodTypeToPb(methodType domain.APIAuthMethodType) app.APIAuthMetho return app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC } } + +func GetApplicationKeyQueriesRequestToDomain(orgID, projectID, appID string) ([]query.SearchQuery, error) { + var searchQueries []query.SearchQuery + + orgID, projectID, appID = strings.TrimSpace(orgID), strings.TrimSpace(projectID), strings.TrimSpace(appID) + + if orgID != "" { + resourceOwner, err := query.NewAuthNKeyResourceOwnerQuery(orgID) + if err != nil { + return nil, err + } + + searchQueries = append(searchQueries, resourceOwner) + } + + if projectID != "" { + aggregateID, err := query.NewAuthNKeyAggregateIDQuery(projectID) + if err != nil { + return nil, err + } + + searchQueries = append(searchQueries, aggregateID) + } + + if appID != "" { + objectID, err := query.NewAuthNKeyObjectIDQuery(appID) + + if err != nil { + return nil, err + } + + searchQueries = append(searchQueries, objectID) + } + + return searchQueries, nil +} diff --git a/internal/api/grpc/app/v2beta/convert/api_app_test.go b/internal/api/grpc/app/v2beta/convert/api_app_test.go index 9f15c3df76..dcb87d712f 100644 --- a/internal/api/grpc/app/v2beta/convert/api_app_test.go +++ b/internal/api/grpc/app/v2beta/convert/api_app_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -147,3 +148,71 @@ func Test_apiAuthMethodTypeToPb(t *testing.T) { }) } } +func TestGetApplicationKeyQueriesRequestToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputOrgID string + inputProjectID string + inputAppID string + + expectedQueriesLength int + }{ + { + testName: "all IDs provided", + inputOrgID: "org-1", + inputProjectID: "proj-1", + inputAppID: "app-1", + expectedQueriesLength: 3, + }, + { + testName: "only org ID", + inputOrgID: "org-1", + inputProjectID: " ", + inputAppID: "", + expectedQueriesLength: 1, + }, + { + testName: "only project ID", + inputOrgID: "", + inputProjectID: "proj-1", + inputAppID: " ", + expectedQueriesLength: 1, + }, + { + testName: "only app ID", + inputOrgID: " ", + inputProjectID: "", + inputAppID: "app-1", + expectedQueriesLength: 1, + }, + { + testName: "empty IDs", + inputOrgID: " ", + inputProjectID: " ", + inputAppID: " ", + expectedQueriesLength: 0, + }, + { + testName: "with spaces", + inputOrgID: " org-1 ", + inputProjectID: " proj-1 ", + inputAppID: " app-1 ", + expectedQueriesLength: 3, + }, + } + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + got, err := GetApplicationKeyQueriesRequestToDomain(tc.inputOrgID, tc.inputProjectID, tc.inputAppID) + + // Then + require.NoError(t, err) + + assert.Len(t, got, tc.expectedQueriesLength) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/convert/convert.go b/internal/api/grpc/app/v2beta/convert/convert.go index c732b3a0c5..a0a1d5ef05 100644 --- a/internal/api/grpc/app/v2beta/convert/convert.go +++ b/internal/api/grpc/app/v2beta/convert/convert.go @@ -2,6 +2,7 @@ package convert import ( "net/url" + "strings" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -9,6 +10,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" @@ -163,3 +165,98 @@ func appQueryToModel(appQuery *app.ApplicationSearchFilter) (query.SearchQuery, return nil, zerrors.ThrowInvalidArgument(nil, "CONV-z2mAGy", "List.Query.Invalid") } } + +func CreateAPIClientKeyRequestToDomain(key *app.CreateApplicationKeyRequest) *domain.ApplicationKey { + return &domain.ApplicationKey{ + ObjectRoot: models.ObjectRoot{ + AggregateID: strings.TrimSpace(key.GetProjectId()), + }, + ExpirationDate: key.GetExpirationDate().AsTime(), + Type: domain.AuthNKeyTypeJSON, + ApplicationID: strings.TrimSpace(key.GetAppId()), + } +} + +func ListApplicationKeysRequestToDomain(sysDefaults systemdefaults.SystemDefaults, req *app.ListApplicationKeysRequest) (*query.AuthNKeySearchQueries, error) { + var queries []query.SearchQuery + + switch req.GetResourceId().(type) { + case *app.ListApplicationKeysRequest_ApplicationId: + object, err := query.NewAuthNKeyObjectIDQuery(strings.TrimSpace(req.GetApplicationId())) + if err != nil { + return nil, err + } + queries = append(queries, object) + case *app.ListApplicationKeysRequest_OrganizationId: + resourceOwner, err := query.NewAuthNKeyResourceOwnerQuery(strings.TrimSpace(req.GetOrganizationId())) + if err != nil { + return nil, err + } + queries = append(queries, resourceOwner) + case *app.ListApplicationKeysRequest_ProjectId: + aggregate, err := query.NewAuthNKeyAggregateIDQuery(strings.TrimSpace(req.GetProjectId())) + if err != nil { + return nil, err + } + queries = append(queries, aggregate) + case nil: + + default: + return nil, zerrors.ThrowInvalidArgument(nil, "CONV-t3ENme", "unexpected resource id") + } + + offset, limit, asc, err := filter.PaginationPbToQuery(sysDefaults, req.GetPagination()) + if err != nil { + return nil, err + } + + return &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: appKeysSortingToColumn(req.GetSortingColumn()), + }, + + Queries: queries, + }, nil +} + +func appKeysSortingToColumn(sortingCriteria app.ApplicationKeysSorting) query.Column { + switch sortingCriteria { + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_PROJECT_ID: + return query.AuthNKeyColumnAggregateID + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_CREATION_DATE: + return query.AuthNKeyColumnCreationDate + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_EXPIRATION: + return query.AuthNKeyColumnExpiration + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_ORGANIZATION_ID: + return query.AuthNKeyColumnResourceOwner + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_TYPE: + return query.AuthNKeyColumnType + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_APPLICATION_ID: + return query.AuthNKeyColumnObjectID + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_ID: + fallthrough + default: + return query.AuthNKeyColumnID + } +} + +func ApplicationKeysToPb(keys []*query.AuthNKey) []*app.ApplicationKey { + pbAppKeys := make([]*app.ApplicationKey, len(keys)) + + for i, k := range keys { + pbKey := &app.ApplicationKey{ + Id: k.ID, + ApplicationId: k.ApplicationID, + ProjectId: k.AggregateID, + CreationDate: timestamppb.New(k.CreationDate), + OrganizationId: k.ResourceOwner, + ExpirationDate: timestamppb.New(k.Expiration), + } + pbAppKeys[i] = pbKey + } + + return pbAppKeys +} diff --git a/internal/api/grpc/app/v2beta/convert/convert_test.go b/internal/api/grpc/app/v2beta/convert/convert_test.go index 5835691d43..8715d2a5dd 100644 --- a/internal/api/grpc/app/v2beta/convert/convert_test.go +++ b/internal/api/grpc/app/v2beta/convert/convert_test.go @@ -518,3 +518,187 @@ func TestAppQueryToModel(t *testing.T) { }) } } + +func TestListApplicationKeysRequestToDomain(t *testing.T) { + t.Parallel() + + resourceOwnerQuery, err := query.NewAuthNKeyResourceOwnerQuery("org1") + require.NoError(t, err) + + projectIDQuery, err := query.NewAuthNKeyAggregateIDQuery("project1") + require.NoError(t, err) + + appIDQuery, err := query.NewAuthNKeyObjectIDQuery("app1") + require.NoError(t, err) + + sysDefaults := systemdefaults.SystemDefaults{DefaultQueryLimit: 100, MaxQueryLimit: 150} + + tt := []struct { + name string + req *app.ListApplicationKeysRequest + + expectedResult *query.AuthNKeySearchQueries + expectedError error + }{ + { + name: "invalid pagination limit", + req: &app.ListApplicationKeysRequest{ + Pagination: &filter_pb_v2.PaginationRequest{Asc: true, Limit: uint32(sysDefaults.MaxQueryLimit + 1)}, + }, + expectedResult: nil, + expectedError: zerrors.ThrowInvalidArgumentf(fmt.Errorf("given: %d, allowed: %d", sysDefaults.MaxQueryLimit+1, sysDefaults.MaxQueryLimit), "QUERY-4M0fs", "Errors.Query.LimitExceeded"), + }, + { + name: "empty request", + req: &app.ListApplicationKeysRequest{ + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResult: &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AuthNKeyColumnID, + }, + Queries: nil, + }, + }, + { + name: "only organization id", + req: &app.ListApplicationKeysRequest{ + ResourceId: &app.ListApplicationKeysRequest_OrganizationId{OrganizationId: "org1"}, + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResult: &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AuthNKeyColumnID, + }, + Queries: []query.SearchQuery{ + resourceOwnerQuery, + }, + }, + }, + { + name: "only project id", + req: &app.ListApplicationKeysRequest{ + ResourceId: &app.ListApplicationKeysRequest_ProjectId{ProjectId: "project1"}, + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResult: &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AuthNKeyColumnID, + }, + Queries: []query.SearchQuery{ + projectIDQuery, + }, + }, + }, + { + name: "only application id", + req: &app.ListApplicationKeysRequest{ + ResourceId: &app.ListApplicationKeysRequest_ApplicationId{ApplicationId: "app1"}, + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResult: &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AuthNKeyColumnID, + }, + Queries: []query.SearchQuery{ + appIDQuery, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result, err := ListApplicationKeysRequestToDomain(sysDefaults, tc.req) + + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResult, result) + }) + } +} + +func TestApplicationKeysToPb(t *testing.T) { + t.Parallel() + + now := time.Now() + + tt := []struct { + name string + input []*query.AuthNKey + expected []*app.ApplicationKey + }{ + { + name: "multiple keys", + input: []*query.AuthNKey{ + { + ID: "key1", + AggregateID: "project1", + ApplicationID: "app1", + CreationDate: now, + ResourceOwner: "org1", + Expiration: now.Add(24 * time.Hour), + Type: domain.AuthNKeyTypeJSON, + }, + { + ID: "key2", + AggregateID: "project2", + ApplicationID: "app1", + CreationDate: now.Add(-time.Hour), + ResourceOwner: "org2", + Expiration: now.Add(48 * time.Hour), + Type: domain.AuthNKeyTypeNONE, + }, + }, + expected: []*app.ApplicationKey{ + { + Id: "key1", + ApplicationId: "app1", + ProjectId: "project1", + CreationDate: timestamppb.New(now), + OrganizationId: "org1", + ExpirationDate: timestamppb.New(now.Add(24 * time.Hour)), + }, + { + Id: "key2", + ApplicationId: "app1", + ProjectId: "project2", + CreationDate: timestamppb.New(now.Add(-time.Hour)), + OrganizationId: "org2", + ExpirationDate: timestamppb.New(now.Add(48 * time.Hour)), + }, + }, + }, + { + name: "empty slice", + input: []*query.AuthNKey{}, + expected: []*app.ApplicationKey{}, + }, + { + name: "nil input", + input: nil, + expected: []*app.ApplicationKey{}, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := ApplicationKeysToPb(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/integration_test/app_key_test.go b/internal/api/grpc/app/v2beta/integration_test/app_key_test.go new file mode 100644 index 0000000000..7c3c886cff --- /dev/null +++ b/internal/api/grpc/app/v2beta/integration_test/app_key_test.go @@ -0,0 +1,206 @@ +//go:build integration + +package app_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "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/timestamppb" + + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func TestCreateApplicationKey(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + createdApp := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + creationRequest *app.CreateApplicationKeyRequest + inputCtx context.Context + + expectedErrorType codes.Code + }{ + { + testName: "when app id is not found should return failed precondition", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationKeyRequest{ + ProjectId: p.GetId(), + AppId: gofakeit.UUID(), + ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()), + }, + expectedErrorType: codes.FailedPrecondition, + }, + { + testName: "when CreateAPIApp request is valid should create app and return no error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationKeyRequest{ + ProjectId: p.GetId(), + AppId: createdApp.GetAppId(), + ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()), + }, + }, + + // LoginUser + { + testName: "when user has no project.app.write permission for app key generation should return permission error", + inputCtx: LoginUserCtx, + creationRequest: &app.CreateApplicationKeyRequest{ + ProjectId: p.GetId(), + AppId: createdApp.GetAppId(), + ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()), + }, + expectedErrorType: codes.PermissionDenied, + }, + + // OrgOwner + { + testName: "when user is OrgOwner app key request should succeed", + inputCtx: OrgOwnerCtx, + creationRequest: &app.CreateApplicationKeyRequest{ + ProjectId: p.GetId(), + AppId: createdApp.GetAppId(), + ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()), + }, + }, + + // ProjectOwner + { + testName: "when user is ProjectOwner app key request should succeed", + inputCtx: projectOwnerCtx, + creationRequest: &app.CreateApplicationKeyRequest{ + ProjectId: p.GetId(), + AppId: createdApp.GetAppId(), + ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.CreateApplicationKey(tc.inputCtx, tc.creationRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetId()) + assert.NotZero(t, res.GetCreationDate()) + assert.NotZero(t, res.GetKeyDetails()) + } + }) + } +} + +func TestDeleteApplicationKey(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + createdApp := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + deletionRequest func(ttt *testing.T) *app.DeleteApplicationKeyRequest + inputCtx context.Context + + expectedErrorType codes.Code + }{ + { + testName: "when app key ID is not found should return not found error", + inputCtx: IAMOwnerCtx, + deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest { + return &app.DeleteApplicationKeyRequest{ + Id: gofakeit.UUID(), + ProjectId: p.GetId(), + ApplicationId: createdApp.GetAppId(), + } + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when valid app key ID should delete successfully", + inputCtx: IAMOwnerCtx, + deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest { + createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1)) + + return &app.DeleteApplicationKeyRequest{ + Id: createdAppKey.GetId(), + ProjectId: p.GetId(), + ApplicationId: createdApp.GetAppId(), + } + }, + }, + + // LoginUser + { + testName: "when user has no project.app.write permission for app key deletion should return permission error", + inputCtx: LoginUserCtx, + deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest { + createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1)) + + return &app.DeleteApplicationKeyRequest{ + Id: createdAppKey.GetId(), + ProjectId: p.GetId(), + ApplicationId: createdApp.GetAppId(), + } + }, + expectedErrorType: codes.PermissionDenied, + }, + + // ProjectOwner + { + testName: "when user is OrgOwner API request should succeed", + inputCtx: projectOwnerCtx, + deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest { + createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1)) + + return &app.DeleteApplicationKeyRequest{ + Id: createdAppKey.GetId(), + ProjectId: p.GetId(), + ApplicationId: createdApp.GetAppId(), + } + }, + }, + + // OrganizationOwner + { + testName: "when user is OrgOwner app key deletion request should succeed", + inputCtx: OrgOwnerCtx, + deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest { + createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1)) + + return &app.DeleteApplicationKeyRequest{ + Id: createdAppKey.GetId(), + ProjectId: p.GetId(), + ApplicationId: createdApp.GetAppId(), + } + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // Given + deletionReq := tc.deletionRequest(t) + + // When + res, err := instance.Client.AppV2Beta.DeleteApplicationKey(tc.inputCtx, deletionReq) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotEmpty(t, res.GetDeletionDate()) + } + }) + } +} diff --git a/internal/api/grpc/app/v2beta/integration_test/app_test.go b/internal/api/grpc/app/v2beta/integration_test/app_test.go index 1ba46987cf..67e59aa91d 100644 --- a/internal/api/grpc/app/v2beta/integration_test/app_test.go +++ b/internal/api/grpc/app/v2beta/integration_test/app_test.go @@ -1,6 +1,6 @@ //go:build integration -package instance_test +package app_test import ( "context" @@ -653,9 +653,9 @@ func TestUpdateApplication_WithDifferentPermissions(t *testing.T) { }) require.Nil(t, appNameChangeErr) - appForAPIConfigChangeForProjectOwner := createAPIApp(t, p.GetId()) - appForAPIConfigChangeForOrgOwner := createAPIApp(t, p.GetId()) - appForAPIConfigChangeForLoginUser := createAPIApp(t, p.GetId()) + appForAPIConfigChangeForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appForAPIConfigChangeForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appForAPIConfigChangeForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) appForOIDCConfigChangeForProjectOwner := createOIDCApp(t, baseURI, p.GetId()) appForOIDCConfigChangeForOrgOwner := createOIDCApp(t, baseURI, p.GetId()) @@ -914,9 +914,9 @@ func TestDeleteApplication(t *testing.T) { func TestDeleteApplication_WithDifferentPermissions(t *testing.T) { p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) - appToDeleteForLoginUser := createAPIApp(t, p.GetId()) - appToDeleteForProjectOwner := createAPIApp(t, p.GetId()) - appToDeleteForOrgOwner := createAPIApp(t, p.GetId()) + appToDeleteForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appToDeleteForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appToDeleteForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) t.Parallel() tt := []struct { @@ -1035,9 +1035,9 @@ func TestDeactivateApplication(t *testing.T) { func TestDeactivateApplication_WithDifferentPermissions(t *testing.T) { p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) - appToDeactivateForLoginUser := createAPIApp(t, p.GetId()) - appToDeactivateForPrjectOwner := createAPIApp(t, p.GetId()) - appToDeactivateForOrgOwner := createAPIApp(t, p.GetId()) + appToDeactivateForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appToDeactivateForPrjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appToDeactivateForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) t.Parallel() @@ -1162,13 +1162,13 @@ func TestReactivateApplication(t *testing.T) { func TestReactivateApplication_WithDifferentPermissions(t *testing.T) { p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) - appToReactivateForLoginUser := createAPIApp(t, p.GetId()) + appToReactivateForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) deactivateApp(t, appToReactivateForLoginUser, p.GetId()) - appToReactivateForProjectOwner := createAPIApp(t, p.GetId()) + appToReactivateForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) deactivateApp(t, appToReactivateForProjectOwner, p.GetId()) - appToReactivateForOrgOwner := createAPIApp(t, p.GetId()) + appToReactivateForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) deactivateApp(t, appToReactivateForOrgOwner, p.GetId()) t.Parallel() @@ -1342,9 +1342,9 @@ func TestRegenerateClientSecret(t *testing.T) { func TestRegenerateClientSecret_WithDifferentPermissions(t *testing.T) { p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) - apiAppToRegenForLoginUser := createAPIApp(t, p.GetId()) - apiAppToRegenForProjectOwner := createAPIApp(t, p.GetId()) - apiAppToRegenForOrgOwner := createAPIApp(t, p.GetId()) + apiAppToRegenForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + apiAppToRegenForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + apiAppToRegenForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) oidcAppToRegenForLoginUser := createOIDCApp(t, baseURI, p.GetId()) oidcAppToRegenForProjectOwner := createOIDCApp(t, baseURI, p.GetId()) diff --git a/internal/api/grpc/app/v2beta/integration_test/query_test.go b/internal/api/grpc/app/v2beta/integration_test/query_test.go index 578fcec138..4f6679da7f 100644 --- a/internal/api/grpc/app/v2beta/integration_test/query_test.go +++ b/internal/api/grpc/app/v2beta/integration_test/query_test.go @@ -1,6 +1,6 @@ //go:build integration -package instance_test +package app_test import ( "context" @@ -165,9 +165,9 @@ func TestListApplications(t *testing.T) { t.Parallel() - createdApiApp, apiAppName := createAPIAppWithName(t, p.GetId()) + createdApiApp, apiAppName := createAPIAppWithName(t, IAMOwnerCtx, instance, p.GetId()) - createdDeactivatedApiApp, deactivatedApiAppName := createAPIAppWithName(t, p.GetId()) + createdDeactivatedApiApp, deactivatedApiAppName := createAPIAppWithName(t, IAMOwnerCtx, instance, p.GetId()) deactivateApp(t, createdDeactivatedApiApp, p.GetId()) _, createdSAMLApp, samlAppName := createSAMLAppWithName(t, gofakeit.URL(), p.GetId()) @@ -573,3 +573,248 @@ func TestListApplications_WithPermissionV2(t *testing.T) { }) } } + +func TestGetApplicationKey(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + createdApiApp := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + createdAppKey := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp.GetAppId(), time.Now().AddDate(0, 0, 1)) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.GetApplicationKeyRequest + inputCtx context.Context + + expectedErrorType codes.Code + expectedAppKeyID string + }{ + { + testName: "when unknown app ID should return not found error", + inputCtx: IAMOwnerCtx, + inputRequest: &app.GetApplicationKeyRequest{ + Id: gofakeit.Sentence(2), + }, + + expectedErrorType: codes.NotFound, + }, + { + testName: "when user has no permission should return membership not found error", + inputCtx: NoPermissionCtx, + inputRequest: &app.GetApplicationKeyRequest{ + Id: createdAppKey.GetId(), + }, + + expectedErrorType: codes.NotFound, + }, + { + testName: "when providing API app ID should return valid API app result", + inputCtx: projectOwnerCtx, + inputRequest: &app.GetApplicationKeyRequest{ + Id: createdAppKey.GetId(), + }, + + expectedAppKeyID: createdAppKey.GetId(), + }, + { + testName: "when user is OrgOwner should return request key", + inputCtx: OrgOwnerCtx, + inputRequest: &app.GetApplicationKeyRequest{ + Id: createdAppKey.GetId(), + ProjectId: p.GetId(), + }, + + expectedAppKeyID: createdAppKey.GetId(), + }, + { + testName: "when user is IAMOwner should return request key", + inputCtx: OrgOwnerCtx, + inputRequest: &app.GetApplicationKeyRequest{ + Id: createdAppKey.GetId(), + OrganizationId: instance.DefaultOrg.GetId(), + }, + + expectedAppKeyID: createdAppKey.GetId(), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 30*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instance.Client.AppV2Beta.GetApplicationKey(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + + assert.Equal(t, tc.expectedAppKeyID, res.GetId()) + assert.NotEmpty(t, res.GetCreationDate()) + assert.NotEmpty(t, res.GetExpirationDate()) + } + }, retryDuration, tick) + }) + } +} + +func TestListApplicationKeys(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + createdApiApp1 := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + createdApiApp2 := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + + tomorrow := time.Now().AddDate(0, 0, 1) + in2Days := tomorrow.AddDate(0, 0, 1) + in3Days := in2Days.AddDate(0, 0, 1) + + appKey1 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp1.GetAppId(), in2Days) + appKey2 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp1.GetAppId(), in3Days) + appKey3 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp1.GetAppId(), tomorrow) + appKey4 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp2.GetAppId(), tomorrow) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.ListApplicationKeysRequest + deps func() (projectID, applicationID, organizationID string) + inputCtx context.Context + + expectedErrorType codes.Code + expectedAppKeysIDs []string + }{ + { + testName: "when sorting by expiration date should return keys sorted by expiration date ascending", + inputCtx: LoginUserCtx, + inputRequest: &app.ListApplicationKeysRequest{ + ResourceId: &app.ListApplicationKeysRequest_ProjectId{ProjectId: p.GetId()}, + Pagination: &filter.PaginationRequest{Asc: true}, + SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_EXPIRATION, + }, + expectedAppKeysIDs: []string{appKey3.GetId(), appKey4.GetId(), appKey1.GetId(), appKey2.GetId()}, + }, + { + testName: "when sorting by creation date should return keys sorted by creation date descending", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationKeysRequest{ + ResourceId: &app.ListApplicationKeysRequest_ProjectId{ProjectId: p.GetId()}, + SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_CREATION_DATE, + }, + expectedAppKeysIDs: []string{appKey4.GetId(), appKey3.GetId(), appKey2.GetId(), appKey1.GetId()}, + }, + { + testName: "when filtering by app ID should return keys matching app ID sorted by ID", + inputCtx: projectOwnerCtx, + inputRequest: &app.ListApplicationKeysRequest{ + Pagination: &filter.PaginationRequest{Asc: true}, + ResourceId: &app.ListApplicationKeysRequest_ApplicationId{ApplicationId: createdApiApp1.GetAppId()}, + }, + expectedAppKeysIDs: []string{appKey1.GetId(), appKey2.GetId(), appKey3.GetId()}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 5*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instance.Client.AppV2Beta.ListApplicationKeys(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(ttt, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + require.Len(ttt, res.GetKeys(), len(tc.expectedAppKeysIDs)) + + for i, k := range res.GetKeys() { + assert.Equal(ttt, tc.expectedAppKeysIDs[i], k.GetId()) + } + } + }, retryDuration, tick) + }) + } +} + +func TestListApplicationKeys_WithPermissionV2(t *testing.T) { + ensureFeaturePermissionV2Enabled(t, instancePermissionV2) + iamOwnerCtx := instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeIAMOwner) + loginUserCtx := instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeLogin) + p, projectOwnerCtx := getProjectAndProjectContext(t, instancePermissionV2, iamOwnerCtx) + + createdApiApp1 := createAPIApp(t, iamOwnerCtx, instancePermissionV2, p.GetId()) + createdApiApp2 := createAPIApp(t, iamOwnerCtx, instancePermissionV2, p.GetId()) + + tomorrow := time.Now().AddDate(0, 0, 1) + in2Days := tomorrow.AddDate(0, 0, 1) + in3Days := in2Days.AddDate(0, 0, 1) + + appKey1 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp1.GetAppId(), in2Days) + appKey2 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp1.GetAppId(), in3Days) + appKey3 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp1.GetAppId(), tomorrow) + appKey4 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp2.GetAppId(), tomorrow) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.ListApplicationKeysRequest + deps func() (projectID, applicationID, organizationID string) + inputCtx context.Context + + expectedErrorType codes.Code + expectedAppKeysIDs []string + }{ + { + testName: "when sorting by expiration date should return keys sorted by expiration date ascending", + inputCtx: loginUserCtx, + inputRequest: &app.ListApplicationKeysRequest{ + Pagination: &filter.PaginationRequest{Asc: true}, + SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_EXPIRATION, + }, + expectedAppKeysIDs: []string{appKey3.GetId(), appKey4.GetId(), appKey1.GetId(), appKey2.GetId()}, + }, + { + testName: "when sorting by creation date should return keys sorted by creation date descending", + inputCtx: iamOwnerCtx, + inputRequest: &app.ListApplicationKeysRequest{ + SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_CREATION_DATE, + }, + expectedAppKeysIDs: []string{appKey4.GetId(), appKey3.GetId(), appKey2.GetId(), appKey1.GetId()}, + }, + { + testName: "when filtering by app ID should return keys matching app ID sorted by ID", + inputCtx: projectOwnerCtx, + inputRequest: &app.ListApplicationKeysRequest{ + Pagination: &filter.PaginationRequest{Asc: true}, + ResourceId: &app.ListApplicationKeysRequest_ApplicationId{ApplicationId: createdApiApp1.GetAppId()}, + }, + expectedAppKeysIDs: []string{appKey1.GetId(), appKey2.GetId(), appKey3.GetId()}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // t.Parallel() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 5*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instancePermissionV2.Client.AppV2Beta.ListApplicationKeys(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(ttt, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + require.Len(ttt, res.GetKeys(), len(tc.expectedAppKeysIDs)) + + for i, k := range res.GetKeys() { + assert.Equal(ttt, tc.expectedAppKeysIDs[i], k.GetId()) + } + } + }, retryDuration, tick) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/integration_test/server_test.go b/internal/api/grpc/app/v2beta/integration_test/server_test.go index 6618ab0616..8ba012c18b 100644 --- a/internal/api/grpc/app/v2beta/integration_test/server_test.go +++ b/internal/api/grpc/app/v2beta/integration_test/server_test.go @@ -1,6 +1,6 @@ //go:build integration -package instance_test +package app_test import ( "context" @@ -13,6 +13,7 @@ import ( "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" app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" @@ -150,14 +151,14 @@ func createOIDCApp(t *testing.T, baseURI, projctID string) *app.CreateApplicatio return app } -func createAPIAppWithName(t *testing.T, projectID string) (*app.CreateApplicationResponse, string) { +func createAPIAppWithName(t *testing.T, ctx context.Context, inst *integration.Instance, projectID string) (*app.CreateApplicationResponse, string) { appName := gofakeit.AppName() reqForAPIAppCreation := &app.CreateApplicationRequest_ApiRequest{ ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, } - appForAPIConfigChange, appAPIConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + appForAPIConfigChange, appAPIConfigChangeErr := inst.Client.AppV2Beta.CreateApplication(ctx, &app.CreateApplicationRequest{ ProjectId: projectID, Name: appName, CreationRequestType: reqForAPIAppCreation, @@ -167,8 +168,8 @@ func createAPIAppWithName(t *testing.T, projectID string) (*app.CreateApplicatio return appForAPIConfigChange, appName } -func createAPIApp(t *testing.T, projectID string) *app.CreateApplicationResponse { - res, _ := createAPIAppWithName(t, projectID) +func createAPIApp(t *testing.T, ctx context.Context, inst *integration.Instance, projectID string) *app.CreateApplicationResponse { + res, _ := createAPIAppWithName(t, ctx, inst, projectID) return res } @@ -203,3 +204,17 @@ func ensureFeaturePermissionV2Enabled(t *testing.T, instance *integration.Instan assert.True(tt, f.PermissionCheckV2.GetEnabled()) }, retryDuration, tick, "timed out waiting for ensuring instance feature") } + +func createAppKey(t *testing.T, ctx context.Context, inst *integration.Instance, projectID, appID string, expirationDate time.Time) *app.CreateApplicationKeyResponse { + res, err := inst.Client.AppV2Beta.CreateApplicationKey(ctx, + &app.CreateApplicationKeyRequest{ + AppId: appID, + ProjectId: projectID, + ExpirationDate: timestamppb.New(expirationDate.UTC()), + }, + ) + + require.Nil(t, err) + + return res +} diff --git a/internal/api/grpc/app/v2beta/query.go b/internal/api/grpc/app/v2beta/query.go index add8af83e6..2926884520 100644 --- a/internal/api/grpc/app/v2beta/query.go +++ b/internal/api/grpc/app/v2beta/query.go @@ -2,9 +2,13 @@ package app import ( "context" + "strings" + + "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/query" app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" ) @@ -35,3 +39,38 @@ func (s *Server) ListApplications(ctx context.Context, req *app.ListApplications Pagination: filter.QueryToPaginationPb(queries.SearchRequest, res.SearchResponse), }, nil } + +func (s *Server) GetApplicationKey(ctx context.Context, req *app.GetApplicationKeyRequest) (*app.GetApplicationKeyResponse, error) { + queries, err := convert.GetApplicationKeyQueriesRequestToDomain(req.GetOrganizationId(), req.GetProjectId(), req.GetApplicationId()) + if err != nil { + return nil, err + } + + key, err := s.query.GetAuthNKeyByIDWithPermission(ctx, true, strings.TrimSpace(req.GetId()), s.checkPermission, queries...) + if err != nil { + return nil, err + } + + return &app.GetApplicationKeyResponse{ + Id: key.ID, + CreationDate: timestamppb.New(key.CreationDate), + ExpirationDate: timestamppb.New(key.Expiration), + }, nil +} + +func (s *Server) ListApplicationKeys(ctx context.Context, req *app.ListApplicationKeysRequest) (*app.ListApplicationKeysResponse, error) { + queries, err := convert.ListApplicationKeysRequestToDomain(s.systemDefaults, req) + if err != nil { + return nil, err + } + + res, err := s.query.SearchAuthNKeys(ctx, queries, query.JoinFilterUnspecified, s.checkPermission) + if err != nil { + return nil, err + } + + return &app.ListApplicationKeysResponse{ + Keys: convert.ApplicationKeysToPb(res.AuthNKeys), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, res.SearchResponse), + }, nil +} diff --git a/internal/api/grpc/management/project_application_converter.go b/internal/api/grpc/management/project_application_converter.go index 186cedc933..fa31565445 100644 --- a/internal/api/grpc/management/project_application_converter.go +++ b/internal/api/grpc/management/project_application_converter.go @@ -177,7 +177,7 @@ func AddAPIClientKeyRequestToDomain(key *mgmt_pb.AddAppKeyRequest) *domain.Appli } func ListAPIClientKeysRequestToQuery(ctx context.Context, req *mgmt_pb.ListAppKeysRequest) (*query.AuthNKeySearchQueries, error) { - resourcOwner, err := query.NewAuthNKeyResourceOwnerQuery(authz.GetCtxData(ctx).OrgID) + resourceOwner, err := query.NewAuthNKeyResourceOwnerQuery(authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -197,7 +197,7 @@ func ListAPIClientKeysRequestToQuery(ctx context.Context, req *mgmt_pb.ListAppKe Asc: asc, }, Queries: []query.SearchQuery{ - resourcOwner, + resourceOwner, projectID, appID, }, diff --git a/internal/command/project_application_key.go b/internal/command/project_application_key.go index 519e9fc30a..47dacdd638 100644 --- a/internal/command/project_application_key.go +++ b/internal/command/project_application_key.go @@ -38,6 +38,11 @@ func (c *Commands) AddApplicationKey(ctx context.Context, key *domain.Applicatio if err != nil { return nil, err } + + if resourceOwner == "" { + resourceOwner = application.ResourceOwner + } + if !application.State.Exists() { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-sak25", "Errors.Project.App.NotFound") } @@ -59,6 +64,10 @@ func (c *Commands) addApplicationKey(ctx context.Context, key *domain.Applicatio return nil, err } + if err := c.checkPermissionUpdateApplication(ctx, keyWriteModel.ResourceOwner, keyWriteModel.AggregateID); err != nil { + return nil, err + } + if !keyWriteModel.KeysAllowed { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Dff54", "Errors.Project.App.AuthMethodNoPrivateKeyJWT") } @@ -110,6 +119,10 @@ func (c *Commands) RemoveApplicationKey(ctx context.Context, projectID, applicat return nil, zerrors.ThrowNotFound(nil, "COMMAND-4m77G", "Errors.Project.App.Key.NotFound") } + if err := c.checkPermissionUpdateApplication(ctx, keyWriteModel.ResourceOwner, keyWriteModel.AggregateID); err != nil { + return nil, err + } + pushedEvents, err := c.eventstore.Push(ctx, project.NewApplicationKeyRemovedEvent(ctx, ProjectAggregateFromWriteModel(&keyWriteModel.WriteModel), keyID)) if err != nil { return nil, err diff --git a/internal/command/project_application_key_test.go b/internal/command/project_application_key_test.go index 9fd46c75f3..3402cab507 100644 --- a/internal/command/project_application_key_test.go +++ b/internal/command/project_application_key_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/domain" + permissionmock "github.com/zitadel/zitadel/internal/domain/mock" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/id" @@ -17,9 +18,10 @@ import ( func TestCommandSide_AddAPIApplicationKey(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - idGenerator id.Generator - keySize int + eventstore func(*testing.T) *eventstore.Eventstore + idGenerator id.Generator + keySize int + permissionCheckMock domain.PermissionCheck } type args struct { ctx context.Context @@ -39,9 +41,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { { name: "no aggregateid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + permissionCheckMock: permissionmock.MockPermissionCheckOK(), }, args: args{ ctx: context.Background(), @@ -57,9 +58,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { { name: "no appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + permissionCheckMock: permissionmock.MockPermissionCheckOK(), }, args: args{ ctx: context.Background(), @@ -77,10 +77,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), + eventstore: expectEventstore(expectFilter()), + permissionCheckMock: permissionmock.MockPermissionCheckOK(), }, args: args{ ctx: context.Background(), @@ -97,10 +95,9 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { }, }, { - name: "create key not allowed, precondition error", + name: "create key not allowed, precondition error 1", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewApplicationAddedEvent(context.Background(), @@ -121,7 +118,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), + permissionCheckMock: permissionmock.MockPermissionCheckOK(), }, args: args{ ctx: context.Background(), @@ -138,10 +136,9 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { }, }, { - name: "create key not allowed, precondition error", + name: "permission check failed", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewApplicationAddedEvent(context.Background(), @@ -162,8 +159,9 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), - keySize: 10, + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), + keySize: 10, + permissionCheckMock: permissionmock.MockPermissionCheckErr(zerrors.ThrowPermissionDenied(nil, "mock.err", "mock permission check failed")), }, args: args{ ctx: context.Background(), @@ -175,6 +173,47 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { }, resourceOwner: "org1", }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, + { + name: "create key not allowed, precondition error 2", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + ), + ), + ), + expectFilter( + eventFromEventPusher( + project.NewAPIConfigAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "client1@project", + "secret", + domain.APIAuthMethodTypeBasic), + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), + keySize: 10, + permissionCheckMock: permissionmock.MockPermissionCheckOK(), + }, + args: args{ + ctx: context.Background(), + key: &domain.ApplicationKey{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, + ApplicationID: "app1", + }, + }, res: res{ err: zerrors.IsPreconditionFailed, }, @@ -183,9 +222,10 @@ func TestCommandSide_AddAPIApplicationKey(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(t), idGenerator: tt.fields.idGenerator, applicationKeySize: tt.fields.keySize, + checkPermission: tt.fields.permissionCheckMock, } got, err := r.AddApplicationKey(tt.args.ctx, tt.args.key, tt.args.resourceOwner) if tt.res.err == nil { diff --git a/internal/domain/mock/permission.go b/internal/domain/mock/permission.go new file mode 100644 index 0000000000..9a3c6478c9 --- /dev/null +++ b/internal/domain/mock/permission.go @@ -0,0 +1,22 @@ +package permissionmock + +import ( + "golang.org/x/net/context" + + "github.com/zitadel/zitadel/internal/domain" +) + +// MockPermissionCheckErr returns a permission check function that will fail +// and return the input error +func MockPermissionCheckErr(err error) domain.PermissionCheck { + return func(_ context.Context, _, _, _ string) error { + return err + } +} + +// MockPermissionCheckOK returns a permission check function that will succeed +func MockPermissionCheckOK() domain.PermissionCheck { + return func(_ context.Context, _, _, _ string) (err error) { + return nil + } +} diff --git a/internal/query/authn_key.go b/internal/query/authn_key.go index 5a1f49d63c..abda5f011e 100644 --- a/internal/query/authn_key.go +++ b/internal/query/authn_key.go @@ -98,6 +98,7 @@ type AuthNKey struct { ChangeDate time.Time ResourceOwner string Sequence uint64 + ApplicationID string Expiration time.Time Type domain.AuthNKeyType @@ -222,6 +223,19 @@ func (q *Queries) SearchAuthNKeysData(ctx context.Context, queries *AuthNKeySear return authNKeys, err } +func (q *Queries) GetAuthNKeyByIDWithPermission(ctx context.Context, shouldTriggerBulk bool, id string, permissionCheck domain.PermissionCheck, queries ...SearchQuery) (*AuthNKey, error) { + key, err := q.GetAuthNKeyByID(ctx, shouldTriggerBulk, id, queries...) + if err != nil { + return nil, err + } + + if err := appCheckPermission(ctx, key.ResourceOwner, key.AggregateID, permissionCheck); err != nil { + return nil, err + } + + return key, nil +} + func (q *Queries) GetAuthNKeyByID(ctx context.Context, shouldTriggerBulk bool, id string, queries ...SearchQuery) (key *AuthNKey, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -330,6 +344,7 @@ func prepareAuthNKeysQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeys AuthNKeyColumnSequence.identifier(), AuthNKeyColumnExpiration.identifier(), AuthNKeyColumnType.identifier(), + AuthNKeyColumnObjectID.identifier(), countColumn.identifier(), ).From(authNKeyTable.identifier()). PlaceholderFormat(sq.Dollar) @@ -348,6 +363,7 @@ func prepareAuthNKeysQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeys &authNKey.Sequence, &authNKey.Expiration, &authNKey.Type, + &authNKey.ApplicationID, &count, ) if err != nil { diff --git a/internal/query/authn_key_test.go b/internal/query/authn_key_test.go index ce45185363..619ffaac8c 100644 --- a/internal/query/authn_key_test.go +++ b/internal/query/authn_key_test.go @@ -26,6 +26,7 @@ var ( ` projections.authn_keys2.sequence,` + ` projections.authn_keys2.expiration,` + ` projections.authn_keys2.type,` + + ` projections.authn_keys2.object_id,` + ` COUNT(*) OVER ()` + ` FROM projections.authn_keys2` prepareAuthNKeysCols = []string{ @@ -37,6 +38,7 @@ var ( "sequence", "expiration", "type", + "object_id", "count", } @@ -129,6 +131,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { uint64(20211109), testNow, 1, + "app1", }, }, ), @@ -147,6 +150,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { Sequence: 20211109, Expiration: testNow, Type: domain.AuthNKeyTypeJSON, + ApplicationID: "app1", }, }, }, @@ -168,6 +172,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { uint64(20211109), testNow, 1, + "app1", }, { "id-2", @@ -178,6 +183,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { uint64(20211109), testNow, 1, + "app1", }, }, ), @@ -196,6 +202,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { Sequence: 20211109, Expiration: testNow, Type: domain.AuthNKeyTypeJSON, + ApplicationID: "app1", }, { ID: "id-2", @@ -206,6 +213,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { Sequence: 20211109, Expiration: testNow, Type: domain.AuthNKeyTypeJSON, + ApplicationID: "app1", }, }, }, diff --git a/proto/zitadel/app/v2beta/app.proto b/proto/zitadel/app/v2beta/app.proto index f85e3c021d..f108f3bacb 100644 --- a/proto/zitadel/app/v2beta/app.proto +++ b/proto/zitadel/app/v2beta/app.proto @@ -92,3 +92,30 @@ message ApplicationNameQuery { } ]; } + +enum ApplicationKeysSorting { + APPLICATION_KEYS_SORT_BY_ID = 0; + APPLICATION_KEYS_SORT_BY_PROJECT_ID = 1; + APPLICATION_KEYS_SORT_BY_APPLICATION_ID = 2; + APPLICATION_KEYS_SORT_BY_CREATION_DATE = 3; + APPLICATION_KEYS_SORT_BY_ORGANIZATION_ID = 4; + APPLICATION_KEYS_SORT_BY_EXPIRATION = 5; + APPLICATION_KEYS_SORT_BY_TYPE = 6; +} + +message ApplicationKey { + string id = 1; + string application_id = 2; + string project_id = 3; + google.protobuf.Timestamp creation_date = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + string organization_id = 5; + google.protobuf.Timestamp expiration_date = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/app_service.proto b/proto/zitadel/app/v2beta/app_service.proto index a881022caa..61cde73696 100644 --- a/proto/zitadel/app/v2beta/app_service.proto +++ b/proto/zitadel/app/v2beta/app_service.proto @@ -114,8 +114,6 @@ service AppService { // // Create an application. The application can be OIDC, API or SAML type, based on the input. // - // The user needs to have project.app.write permission - // // Required permissions: // - project.app.write rpc CreateApplication(CreateApplicationRequest) returns (CreateApplicationResponse) { @@ -145,8 +143,6 @@ service AppService { // Changes the configuration of an OIDC, API or SAML type application, as well as // the application name, based on the input provided. // - // The user needs to have project.app.write permission - // // Required permissions: // - project.app.write rpc UpdateApplication(UpdateApplicationRequest) returns (UpdateApplicationResponse) { @@ -175,8 +171,6 @@ service AppService { // // Retrieves the application matching the provided ID. // - // The user needs to have project.app.read permission - // // Required permissions: // - project.app.read rpc GetApplication(GetApplicationRequest) returns (GetApplicationResponse) { @@ -203,9 +197,7 @@ service AppService { // Delete Application // // Deletes the application belonging to the input project and matching the provided - // application ID - // - // The user needs to have project.app.delete permission + // application ID. // // Required permissions: // - project.app.delete @@ -233,9 +225,7 @@ service AppService { // Deactivate Application // // Deactivates the application belonging to the input project and matching the provided - // application ID - // - // The user needs to have project.app.write permission + // application ID. // // Required permissions: // - project.app.write @@ -264,9 +254,7 @@ service AppService { // Reactivate Application // // Reactivates the application belonging to the input project and matching the provided - // application ID - // - // The user needs to have project.app.write permission + // application ID. // // Required permissions: // - project.app.write @@ -297,8 +285,6 @@ service AppService { // // Regenerates the client secret of an API or OIDC application that belongs to the input project. // - // The user needs to have project.app.write permission - // // Required permissions: // - project.app.write rpc RegenerateClientSecret(RegenerateClientSecretRequest) returns (RegenerateClientSecretResponse) { @@ -331,8 +317,6 @@ service AppService { // The result can be sorted by app id, name, creation date, change date or state. It can also // be filtered by app state, app type and app name. // - // The user needs to have project.app.read permission - // // Required permissions: // - project.app.read rpc ListApplications(ListApplicationsRequest) returns (ListApplicationsResponse) { @@ -356,6 +340,129 @@ service AppService { } }; } + + + // Create Application Key + // + // Create a new application key, which is used to authorize an API application. + // + // Key details are returned in the response. They must be stored safely, as it will not + // be possible to retrieve them again. + // + // Required permissions: + // - `project.app.write` + rpc CreateApplicationKey(CreateApplicationKeyRequest) returns (CreateApplicationKeyResponse) { + option (google.api.http) = { + post: "/v2beta/application_keys" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The created application key"; + } + }; + }; + } + + // Delete Application Key + // + // Deletes an application key matching the provided ID. + // + // Organization ID is not mandatory, but helps with filtering/performance. + // + // The deletion time is returned in response message. + // + // Required permissions: + // - `project.app.write` + rpc DeleteApplicationKey(DeleteApplicationKeyRequest) returns (DeleteApplicationKeyResponse) { + option (google.api.http) = { + delete: "/v2beta/application_keys/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The time of deletion."; + } + }; + }; + } + + // Get Application Key + // + // Retrieves the application key matching the provided ID. + // + // Specifying a project, organization and app ID is optional but help with filtering/performance. + // + // Required permissions: + // - project.app.read + rpc GetApplicationKey(GetApplicationKeyRequest) returns (GetApplicationKeyResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The fetched app key."; + } + }; + }; + + option (google.api.http) = { + get: "/v2beta/application_keys/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // List Application Keys + // + // Returns a list of application keys matching the input parameters. + // + // The result can be sorted by id, aggregate, creation date, expiration date, resource owner or type. + // It can also be filtered by app, project or organization ID. + // + // Required permissions: + // - project.app.read + rpc ListApplicationKeys(ListApplicationKeysRequest) returns (ListApplicationKeysResponse) { + option (google.api.http) = { + post: "/v2beta/application_keys/search" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The matching applications"; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } } message CreateApplicationRequest { @@ -785,4 +892,103 @@ message ListApplicationsResponse { // Contains the total number of apps matching the query and the applied limit. zitadel.filter.v2.PaginationResponse pagination = 2; -} \ No newline at end of file +} + +message CreateApplicationKeyRequest { + string app_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + + string project_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; + + // The date the key will expire + google.protobuf.Timestamp expiration_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2519-04-01T08:45:00.000000Z\""; + description: "The date the key will expire"; + } + ]; +} + +message CreateApplicationKeyResponse { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"28746028909593987\""; + } + ]; + + // The timestamp of the app creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + bytes key_details = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"eyJ0eXBlIjoiYXBwbGljYXRpb24iLCJrZXlJZCI6IjIwMjcxMDE4NjYyMjcxNDExMyIsImtleSI6Ii0tLS0tQkVHSU4gUlNBIFBSSVZBVEUgS0VZLS0tLS1cbk1JSUVvd0lCQUFLQ0FRRUFuMUxyNStTV0pGRllURU1kaXQ2U0dNY0E2Yks5dG0xMmhlcm55V0wrZm9PWnA3eEVcbk9wcmsvWE81QVplSU5NY0x0ZVhxckJlK1NPdVVNMFpLU2xCMHFTNzNjVStDVTVMTGoycVB0UzhNOFI0N3BGdFhcbjJXRTFJNjNhZHB1N01TejA2SXduQ2lyNnJYOTVPQ2ZneHA3VU1Dd0pSTUZmYXJqdjVBRXY3NXpsSS9lYUV6bUJcbkxKWU1xanZFRmZoN2x3M2lPT3VsWW9kNjNpN3RDNWl5czNlYjNLZW4yWU0rN1FSbXB2dE5qcTJMVmlIMnkrUGJcbk9ESlI3MU9ib05TYVJDNTZDUFpWVytoWDByYXI3VzMwUjI2eGtIQ09oSytQbUpSeGtOY0g1VTdja0xXMEw0WEVcbnNNZkVUSmszeDR3Q0psbisxbElXUzkrNmw0R1E2TWRzWURyOU5RSURBUUFCQW9JQkFCSkx6WGQxMHFBZEQwekNcbnNGUFFOMnJNLzVmV3hONThONDR0YWF6QXg0VHp5K050UlZDTmxScGQvYkxuR2VjbHJIeVpDSmYycWcxcHNEMHJcbkowRGRlR2d0VXBFYWxsYk9scjNEZVBsUGkrYnNsK0RKOUk2c0VSUWwxTjZtQjVzZ0ZJZllBR3UwZjlFSXdIem9cblozR25yNnBRaEVmM0JPUVdsTVhVTlJNSksyOHp3M2E1L01nRmtKVUZUSTUzeXFwbGRtZ2hLajRZR1hLRk1LUGhcbkV3RkxrRncwK2s3K0xuSjFQNGp1ZVd1RXo3WlAyaFpvUWxCcXdSajVyTG9QZ05RbUU4UytFVDRuczlUYzByOFFcbnFyaHlacDZBczJrTDhGTytCZnF3SVpDZnpnWHN2cC9PLzRaSHIzVTB2Ymp3UW1sSzdVSm42U0J6T2hpWFpNU0lcbk5Wc0V5VUVDZ1lFQTFEaktkRGo3NTM1MWQzdlRNQlRFd2JSQ3hoUVZOdENFMnMwVUw4ckJQZ1I0K1dlblNUWmFcbnprWUprcEV0bE54VGxzYnN1Y0RTUXZqeWRYYk5nSHFBeDYzMm1vdTVkak9lR0VTUDFWVGtUdElsZFZQZWszQWxcbjVYbkpQa1dqWGVyVVJZNm5KeUQ5UWhlREx3MVp4NEFYVzNHWURiTFkrT05XV0VKUlJaQUloNjBDZ1lFQXdEQ2xcbnc1MHc4dkcvbEJ4RzNSYW9FaHdLOWNna1VXOHk2T25DekNwcEtjOEZUUmY1VE5iWjl5TzNXUmdYajhkeHRCakFcbkl5VGlzYk9NQk1VaFZKUUtGZHRQaDhoVDBwRkRjeE9ndzY0aHBtYzhyY2RTbXVKNzlYSVRTaHUySjA0N0UvNFZcbnJOTThpWVk5ZGR3VGdGUUlsdFNZL0l0RnFxWERmdjhqK1dVY25La0NnWUVBaENOUU80bDNuNjRucWR2WnBTaHBcblVrclJBTkJrWFJyOGZkZ1BaNnFSSS9KWStNSEhjVmg4dGM3NkN0NkdTUmZlbkJVRU5LeVF2czZPK1FDZCtBOU9cbnZBWGZkRjduZldlcVdtWG1RT2g0dDNNMWk1WkxFZlpVUWt2UU9BdllLcFFhMDZ4OCsyb1pCdHZvL0pVTmY2Q0xcbjZvNFNKUVZrLzZOZGtkckpDODBnNG9rQ2dZQkZsNWYrbkVYa1F0dWZVeG5wNXRGWE5XWldsM0ZuTjMvVXpRaW5cbmkxZm5OcnB4cnhPcjJrUzA4KzdwU1FzSEdpNDNDNXRQWG9UajJlTUN1eXNWaUVHYXBuNUc2YWhJb0NjdlhWVWlcblprUnpFQUR0NERZdU5ZS3pYdXBUTkhPaUNmYmtoMlhyM2RXVzZ0QUloSGRmU1k2T3AwNzZhNmYvWWVUSGNMWGpcbkVkVHBlUUtCZ0FPdnBqcDQ4TzRUWEZkU0JLSnYya005OHVhUjlSQURtdGxTWHd2cTlyQkhTV084NFk4bzE0L1Bcbkl1UmxUOHhROGRYKzhMR21UUCtjcUtiOFFRQ1grQk1YUWxMSEVtWnpnb0xFa0pGMUVIMm4vZEZ5bngxS3prdFNcbm9UZUdsRzZhbXhVOVh4eW9RVFlEVGJCbERwc2FZUlFBZ2FUQzM3UVZRUjhmK1ZoRzFHSFFcbi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tXG4iLCJhcHBJZCI6IjIwMjcwNjM5ODgxMzg4MDU3NyIsImNsaWVudElkIjoiMjAyNzA2Mzk4ODEzOTQ2MTEzQG15dGVzdHByb2plY3QifQ==\""; + } + ]; +} + +message DeleteApplicationKeyRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string project_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string application_id = 3 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string organization_id = 4 [(validate.rules).string = {max_len: 200}]; +} + +message DeleteApplicationKeyResponse { + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message GetApplicationKeyRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string project_id = 2 [(validate.rules).string = {max_len: 200}]; + string application_id = 3 [(validate.rules).string = {max_len: 200}]; + string organization_id = 4 [(validate.rules).string = {max_len: 200}]; +} + +message GetApplicationKeyResponse { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + } + ]; + + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + + // the date a key will expire + google.protobuf.Timestamp expiration_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the date a key will expire"; + example: "\"3019-04-01T08:45:00.000000Z\""; + } + ]; +} + +message ListApplicationKeysRequest { + // Pagination and sorting. + zitadel.filter.v2.PaginationRequest pagination = 1; + + ApplicationKeysSorting sorting_column = 2; + + oneof resource_id { + string application_id = 3 [(validate.rules).string = {min_len: 1; max_len: 200}]; + string project_id = 4 [(validate.rules).string = {min_len: 1; max_len: 200}]; + string organization_id = 5 [(validate.rules).string = {min_len: 1; max_len: 200}]; + } +} + +message ListApplicationKeysResponse { + repeated ApplicationKey keys = 1; + + // Contains the total number of app keys matching the query and the applied limit. + zitadel.filter.v2.PaginationResponse pagination = 2; +} diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 74d5dcf60b..bb62e2eba6 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -3709,6 +3709,7 @@ service ManagementService { }; } + // Deprecated: Use [GetApplicationKey](/apis/resources/application_service_v2/application-service-get-application-key.api.mdx) instead to get an application key rpc GetAppKey(GetAppKeyRequest) returns (GetAppKeyResponse) { option (google.api.http) = { get: "/projects/{project_id}/apps/{app_id}/keys/{key_id}" @@ -3731,9 +3732,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [ListApplicationKeys](/apis/resources/application_service_v2/application-service-list-application-keys.api.mdx) instead to list application keys rpc ListAppKeys(ListAppKeysRequest) returns (ListAppKeysResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/keys/_search" @@ -3760,6 +3763,8 @@ service ManagementService { }; } + // Deprecated: Use [CreateApplicationKey](/apis/resources/application_service_v2/application-service-create-application-key.api.mdx) instead to + // create an application key rpc AddAppKey(AddAppKeyRequest) returns (AddAppKeyResponse){ option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/keys" @@ -3783,9 +3788,12 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [DeleteApplicationKey](/apis/resources/application_service_v2/application-service-delete-application-key.api.mdx) instead to + // delete an application key rpc RemoveAppKey(RemoveAppKeyRequest) returns (RemoveAppKeyResponse) { option (google.api.http) = { delete: "/projects/{project_id}/apps/{app_id}/keys/{key_id}" @@ -3808,6 +3816,7 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } From 2928c6ac2bba8799f002fca0548f165f8cfe1be0 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 2 Jul 2025 10:04:19 +0200 Subject: [PATCH 16/66] chore(login): migrate nextjs login to monorepo (#10134) # Which Problems Are Solved We move the login code to the zitadel repo. # How the Problems Are Solved The login repo is added to ./login as a git subtree pulled from the dockerize-ci branch. Apart from the login code, this PR contains the changes from #10116 # Additional Context - Closes https://github.com/zitadel/typescript/issues/474 - Also merges #10116 - Merging is blocked by failing check because of: - https://github.com/zitadel/zitadel/pull/10134#issuecomment-3012086106 --------- Co-authored-by: Max Peintner Co-authored-by: Max Peintner Co-authored-by: Florian Forster --- .github/dependabot.yml | 13 + .github/workflows/build.yml | 28 +- .github/workflows/compile.yml | 36 +- .github/workflows/login-container.yml | 63 + .github/workflows/login-quality.yml | 59 + .github/workflows/release.yml | 70 + .golangci.yaml | 1 + LICENSING.md | 7 + Makefile | 50 +- build/Dockerfile.gitignore | 3 + cmd/defaults.yaml | 10 +- docker-bake.hcl | 5 + dockerfiles/proto-files.Dockerfile | 8 + .../proto-files.Dockerfile.dockerignore | 2 + .../typescript-proto-client.Dockerfile | 8 + ...cript-proto-client.Dockerfile.dockerignore | 11 + e2e/config/host.docker.internal/zitadel.yaml | 3 + e2e/config/localhost/zitadel.yaml | 3 + internal/integration/config/zitadel.yaml | 7 + login/.changeset/README.md | 8 + login/.changeset/config.json | 11 + login/.eslintrc.cjs | 10 + login/.github/ISSUE_TEMPLATE/bug.yaml | 63 + login/.github/ISSUE_TEMPLATE/config.yml | 4 + login/.github/ISSUE_TEMPLATE/docs.yaml | 30 + login/.github/ISSUE_TEMPLATE/improvement.yaml | 54 + login/.github/ISSUE_TEMPLATE/proposal.yaml | 54 + login/.github/custom-i18n.png | Bin 0 -> 85028 bytes login/.github/dependabot.yml | 21 + login/.github/pull_request_template.md | 13 + login/.github/workflows/close_pr.yml | 35 + login/.github/workflows/issues.yml | 41 + login/.github/workflows/release.yml | 32 + login/.github/workflows/test.yml | 67 + login/.gitignore | 18 + login/.npmrc | 1 + login/.nvmrc | 1 + login/.prettierignore | 9 + login/.prettierrc | 6 + login/CODE_OF_CONDUCT.md | 128 + login/CONTRIBUTING.md | 206 + login/LICENSE | 21 + login/Makefile | 137 + login/README.md | 264 + login/acceptance/docker-compose.yaml | 71 + login/apps/login-test-acceptance/.gitignore | 1 + .../docker-compose-ci.yaml | 59 + .../login-test-acceptance/docker-compose.yaml | 237 + .../go-command.Dockerfile | 11 + .../login-test-acceptance/idp/oidc/go.mod | 28 + .../login-test-acceptance/idp/oidc/go.sum | 71 + .../login-test-acceptance/idp/oidc/main.go | 186 + .../login-test-acceptance/idp/saml/go.mod | 16 + .../login-test-acceptance/idp/saml/go.sum | 49 + .../login-test-acceptance/idp/saml/main.go | 328 + .../apps/login-test-acceptance/oidcrp/go.mod | 26 + .../apps/login-test-acceptance/oidcrp/go.sum | 67 + .../apps/login-test-acceptance/oidcrp/main.go | 322 + login/apps/login-test-acceptance/package.json | 18 + .../apps/login-test-acceptance/pat/.gitignore | 2 + login/apps/login-test-acceptance/pat/.gitkeep | 0 .../playwright-report/.gitignore | 2 + .../playwright-report/.gitkeep | 0 .../playwright.config.ts | 78 + .../apps/login-test-acceptance/samlsp/go.mod | 18 + .../apps/login-test-acceptance/samlsp/go.sum | 38 + .../apps/login-test-acceptance/samlsp/main.go | 271 + login/apps/login-test-acceptance/setup/go.mod | 3 + login/apps/login-test-acceptance/setup/go.sum | 0 .../apps/login-test-acceptance/setup/main.go | 3 + .../apps/login-test-acceptance/setup/setup.sh | 139 + login/apps/login-test-acceptance/sink/go.mod | 3 + login/apps/login-test-acceptance/sink/go.sum | 0 login/apps/login-test-acceptance/sink/main.go | 111 + .../test-results/.gitignore | 2 + .../test-results/.gitkeep | 0 .../login-test-acceptance/tests/admin.spec.ts | 7 + .../tests/code-screen.ts | 12 + .../apps/login-test-acceptance/tests/code.ts | 17 + .../tests/email-verify-screen.ts | 12 + .../tests/email-verify.spec.ts | 69 + .../tests/email-verify.ts | 15 + .../tests/idp-apple.spec.ts | 102 + .../tests/idp-generic-jwt.spec.ts | 99 + .../tests/idp-generic-oauth.spec.ts | 99 + .../tests/idp-generic-oidc.spec.ts | 101 + .../tests/idp-github-enterprise.spec.ts | 103 + .../tests/idp-github.spec.ts | 103 + .../tests/idp-gitlab-self-hosted.spec.ts | 103 + .../tests/idp-gitlab.spec.ts | 103 + .../tests/idp-google.spec.ts | 99 + .../tests/idp-ldap.spec.ts | 99 + .../tests/idp-microsoft.spec.ts | 102 + .../tests/idp-saml.spec.ts | 103 + .../login-configuration-possiblities.spec.ts | 57 + .../apps/login-test-acceptance/tests/login.ts | 41 + .../tests/loginname-screen.ts | 12 + .../login-test-acceptance/tests/loginname.ts | 7 + .../login-test-acceptance/tests/passkey.ts | 109 + .../tests/password-screen.ts | 98 + .../login-test-acceptance/tests/password.ts | 29 + .../tests/register-screen.ts | 27 + .../tests/register.spec.ts | 183 + .../login-test-acceptance/tests/register.ts | 39 + .../tests/select-account.ts | 5 + .../apps/login-test-acceptance/tests/sink.ts | 43 + .../apps/login-test-acceptance/tests/user.ts | 177 + .../tests/username-passkey.spec.ts | 43 + .../username-password-change-required.spec.ts | 41 + .../tests/username-password-changed.spec.ts | 54 + .../tests/username-password-otp_email.spec.ts | 98 + .../tests/username-password-otp_sms.spec.ts | 71 + .../tests/username-password-set.spec.ts | 52 + .../tests/username-password-totp.spec.ts | 71 + .../tests/username-password-u2f.spec.ts | 26 + .../tests/username-password.spec.ts | 157 + .../login-test-acceptance/tests/welcome.ts | 6 + .../login-test-acceptance/tests/zitadel.ts | 190 + login/apps/login-test-acceptance/turbo.json | 10 + login/apps/login-test-acceptance/zitadel.yaml | 83 + login/apps/login-test-integration/.gitignore | 2 + .../core-mock/Dockerfile | 9 + .../zitadel.settings.v2.SettingsService.json | 59 + .../core-mock/mocked-services.cfg | 7 + .../login-test-integration/cypress.config.ts | 14 + .../docker-compose.yaml | 30 + .../fixtures/example.json | 5 + .../integration/invite.cy.ts | 110 + .../integration/login.cy.ts | 172 + .../integration/register-idp.cy.ts | 21 + .../integration/register.cy.ts | 73 + .../integration/verify.cy.ts | 95 + .../apps/login-test-integration/package.json | 17 + .../login-test-integration/support/e2e.ts | 29 + .../apps/login-test-integration/tsconfig.json | 8 + login/apps/login-test-integration/turbo.json | 10 + login/apps/login/.env.test | 5 + login/apps/login/.eslintrc.cjs | 12 + login/apps/login/.gitignore | 3 + login/apps/login/.prettierignore | 2 + login/apps/login/constants/csp.js | 2 + login/apps/login/locales/de.json | 250 + login/apps/login/locales/en.json | 250 + login/apps/login/locales/es.json | 250 + login/apps/login/locales/it.json | 250 + login/apps/login/locales/pl.json | 250 + login/apps/login/locales/ru.json | 250 + login/apps/login/locales/zh.json | 250 + login/apps/login/next-env-vars.d.ts | 33 + login/apps/login/next-env.d.ts | 5 + login/apps/login/next.config.mjs | 83 + login/apps/login/package.json | 75 + login/apps/login/postcss.config.cjs | 6 + login/apps/login/prettier.config.mjs | 1 + login/apps/login/public/checkbox.svg | 1 + login/apps/login/public/favicon.ico | Bin 0 -> 15086 bytes .../public/favicon/android-chrome-192x192.png | Bin 0 -> 17828 bytes .../public/favicon/android-chrome-512x512.png | Bin 0 -> 137768 bytes .../login/public/favicon/apple-touch-icon.png | Bin 0 -> 18112 bytes .../login/public/favicon/browserconfig.xml | 9 + .../login/public/favicon/favicon-16x16.png | Bin 0 -> 1551 bytes .../login/public/favicon/favicon-32x32.png | Bin 0 -> 2050 bytes login/apps/login/public/favicon/favicon.ico | Bin 0 -> 15086 bytes .../login/public/favicon/mstile-150x150.png | Bin 0 -> 13206 bytes .../login/public/favicon/site.webmanifest | 19 + login/apps/login/public/grid-dark.svg | 5 + login/apps/login/public/grid-light.svg | 5 + .../logo/zitadel-logo-solo-darkdesign.svg | 74 + .../logo/zitadel-logo-solo-lightdesign.svg | 76 + login/apps/login/public/zitadel-logo-dark.svg | 101 + .../apps/login/public/zitadel-logo-light.svg | 99 + login/apps/login/readme.md | 394 + login/apps/login/screenshots/accounts.png | Bin 0 -> 159830 bytes .../login/screenshots/accounts_jumpto.png | Bin 0 -> 15180 bytes login/apps/login/screenshots/collage.png | Bin 0 -> 288519 bytes login/apps/login/screenshots/idp.png | Bin 0 -> 86616 bytes login/apps/login/screenshots/loginname.png | Bin 0 -> 114853 bytes login/apps/login/screenshots/mfa.png | Bin 0 -> 104053 bytes login/apps/login/screenshots/mfaset.png | Bin 0 -> 116794 bytes login/apps/login/screenshots/otp.png | Bin 0 -> 84122 bytes login/apps/login/screenshots/otpset.png | Bin 0 -> 146885 bytes login/apps/login/screenshots/passkey.png | Bin 0 -> 86883 bytes login/apps/login/screenshots/password.png | Bin 0 -> 84874 bytes .../login/screenshots/password_change.png | Bin 0 -> 123203 bytes login/apps/login/screenshots/password_set.png | Bin 0 -> 153578 bytes login/apps/login/screenshots/register.png | Bin 0 -> 161800 bytes .../login/screenshots/register_password.png | Bin 0 -> 118094 bytes login/apps/login/screenshots/signedin.png | Bin 0 -> 60794 bytes login/apps/login/screenshots/u2f.png | Bin 0 -> 76779 bytes login/apps/login/screenshots/u2fset.png | Bin 0 -> 90769 bytes login/apps/login/screenshots/verify.png | Bin 0 -> 67934 bytes .../login/src/app/(login)/accounts/page.tsx | 97 + .../app/(login)/authenticator/set/page.tsx | 218 + .../src/app/(login)/device/consent/page.tsx | 99 + .../login/src/app/(login)/device/page.tsx | 48 + login/apps/login/src/app/(login)/error.tsx | 27 + .../(login)/idp/[provider]/failure/page.tsx | 105 + .../(login)/idp/[provider]/success/page.tsx | 340 + .../login/src/app/(login)/idp/ldap/page.tsx | 56 + login/apps/login/src/app/(login)/idp/page.tsx | 51 + login/apps/login/src/app/(login)/layout.tsx | 62 + .../login/src/app/(login)/loginname/page.tsx | 93 + .../login/src/app/(login)/logout/page.tsx | 86 + .../src/app/(login)/logout/success/page.tsx | 43 + login/apps/login/src/app/(login)/mfa/page.tsx | 134 + .../login/src/app/(login)/mfa/set/page.tsx | 174 + .../src/app/(login)/otp/[method]/page.tsx | 136 + .../src/app/(login)/otp/[method]/set/page.tsx | 204 + login/apps/login/src/app/(login)/page.tsx | 8 + .../login/src/app/(login)/passkey/page.tsx | 89 + .../src/app/(login)/passkey/set/page.tsx | 85 + .../src/app/(login)/password/change/page.tsx | 100 + .../login/src/app/(login)/password/page.tsx | 102 + .../src/app/(login)/password/set/page.tsx | 137 + .../login/src/app/(login)/register/page.tsx | 136 + .../app/(login)/register/password/page.tsx | 100 + .../login/src/app/(login)/saml-post/route.ts | 30 + .../login/src/app/(login)/signedin/page.tsx | 141 + login/apps/login/src/app/(login)/u2f/page.tsx | 96 + .../login/src/app/(login)/u2f/set/page.tsx | 76 + .../login/src/app/(login)/verify/page.tsx | 174 + .../src/app/(login)/verify/success/page.tsx | 92 + login/apps/login/src/app/global-error.tsx | 36 + login/apps/login/src/app/healthy/route.ts | 5 + login/apps/login/src/app/login/route.ts | 557 + login/apps/login/src/app/security/route.ts | 28 + .../apps/login/src/components/address-bar.tsx | 61 + login/apps/login/src/components/alert.tsx | 45 + .../apps/login/src/components/app-avatar.tsx | 48 + .../login/src/components/auth-methods.tsx | 234 + .../authentication-method-radio.tsx | 104 + login/apps/login/src/components/avatar.tsx | 97 + .../apps/login/src/components/back-button.tsx | 18 + login/apps/login/src/components/boundary.tsx | 83 + login/apps/login/src/components/button.tsx | 74 + .../src/components/change-password-form.tsx | 211 + login/apps/login/src/components/checkbox.tsx | 62 + .../choose-authenticator-to-login.tsx | 38 + .../choose-authenticator-to-setup.tsx | 51 + .../choose-second-factor-to-setup.tsx | 119 + .../src/components/choose-second-factor.tsx | 54 + login/apps/login/src/components/consent.tsx | 116 + .../src/components/copy-to-clipboard.tsx | 41 + .../login/src/components/default-tags.tsx | 32 + .../login/src/components/device-code-form.tsx | 95 + .../login/src/components/dynamic-theme.tsx | 43 + .../login/src/components/external-link.tsx | 21 + .../apps/login/src/components/idp-signin.tsx | 67 + .../login/src/components/idps/base-button.tsx | 41 + .../components/idps/pages/complete-idp.tsx | 55 + .../components/idps/pages/linking-failed.tsx | 27 + .../components/idps/pages/linking-success.tsx | 30 + .../components/idps/pages/login-failed.tsx | 24 + .../components/idps/pages/login-success.tsx | 30 + .../components/idps/sign-in-with-apple.tsx | 36 + .../components/idps/sign-in-with-azure-ad.tsx | 42 + .../components/idps/sign-in-with-generic.tsx | 21 + .../components/idps/sign-in-with-github.tsx | 64 + .../idps/sign-in-with-gitlab.test.tsx | 45 + .../components/idps/sign-in-with-gitlab.tsx | 53 + .../idps/sign-in-with-google.test.tsx | 44 + .../components/idps/sign-in-with-google.tsx | 66 + login/apps/login/src/components/input.tsx | 102 + .../src/components/language-provider.tsx | 13 + .../src/components/language-switcher.tsx | 74 + .../login/src/components/layout-providers.tsx | 17 + .../ldap-username-password-form.tsx | 109 + login/apps/login/src/components/login-otp.tsx | 284 + .../login/src/components/login-passkey.tsx | 280 + login/apps/login/src/components/logo.tsx | 37 + .../components/password-complexity.test.tsx | 64 + .../src/components/password-complexity.tsx | 99 + .../login/src/components/password-form.tsx | 176 + .../components/privacy-policy-checkboxes.tsx | 105 + .../register-form-idp-incomplete.tsx | 156 + .../login/src/components/register-form.tsx | 227 + .../login/src/components/register-passkey.tsx | 220 + .../login/src/components/register-u2f.tsx | 225 + .../src/components/self-service-menu.tsx | 42 + .../src/components/session-clear-item.tsx | 105 + .../login/src/components/session-item.tsx | 156 + .../src/components/sessions-clear-list.tsx | 109 + .../login/src/components/sessions-list.tsx | 50 + .../src/components/set-password-form.tsx | 286 + .../components/set-register-password-form.tsx | 170 + .../login/src/components/sign-in-with-idp.tsx | 93 + .../login/src/components/skeleton-card.tsx | 16 + login/apps/login/src/components/skeleton.tsx | 9 + login/apps/login/src/components/spinner.tsx | 22 + .../apps/login/src/components/state-badge.tsx | 40 + login/apps/login/src/components/tab-group.tsx | 16 + login/apps/login/src/components/tab.tsx | 35 + .../login/src/components/theme-provider.tsx | 16 + .../login/src/components/theme-wrapper.tsx | 18 + login/apps/login/src/components/theme.tsx | 44 + .../login/src/components/totp-register.tsx | 157 + .../apps/login/src/components/translated.tsx | 23 + .../apps/login/src/components/user-avatar.tsx | 59 + .../login/src/components/username-form.tsx | 156 + .../apps/login/src/components/verify-form.tsx | 168 + .../src/components/zitadel-logo-dark.tsx | 210 + .../src/components/zitadel-logo-light.tsx | 210 + .../login/src/components/zitadel-logo.tsx | 32 + login/apps/login/src/helpers/base64.ts | 63 + login/apps/login/src/helpers/colors.ts | 439 + login/apps/login/src/helpers/validators.ts | 19 + login/apps/login/src/i18n/request.ts | 59 + login/apps/login/src/lib/api.ts | 17 + login/apps/login/src/lib/client.ts | 80 + login/apps/login/src/lib/cookies.ts | 341 + login/apps/login/src/lib/demos.ts | 38 + login/apps/login/src/lib/fingerprint.ts | 66 + login/apps/login/src/lib/hooks.ts | 14 + login/apps/login/src/lib/i18n.ts | 38 + login/apps/login/src/lib/idp.ts | 77 + login/apps/login/src/lib/oidc.ts | 132 + login/apps/login/src/lib/saml.ts | 130 + login/apps/login/src/lib/self.ts | 60 + login/apps/login/src/lib/server/cookie.ts | 278 + login/apps/login/src/lib/server/device.ts | 20 + login/apps/login/src/lib/server/idp.ts | 241 + login/apps/login/src/lib/server/loginname.ts | 454 + login/apps/login/src/lib/server/oidc.ts | 15 + login/apps/login/src/lib/server/otp.ts | 83 + login/apps/login/src/lib/server/passkeys.ts | 278 + login/apps/login/src/lib/server/password.ts | 460 + login/apps/login/src/lib/server/register.ts | 233 + login/apps/login/src/lib/server/session.ts | 221 + login/apps/login/src/lib/server/u2f.ts | 103 + login/apps/login/src/lib/server/verify.ts | 329 + login/apps/login/src/lib/service-url.ts | 58 + login/apps/login/src/lib/service.ts | 49 + login/apps/login/src/lib/session.ts | 194 + login/apps/login/src/lib/verify-helper.ts | 289 + login/apps/login/src/lib/zitadel.ts | 1525 +++ login/apps/login/src/middleware.ts | 109 + login/apps/login/src/styles/globals.scss | 65 + login/apps/login/src/styles/vars.scss | 174 + login/apps/login/tailwind.config.mjs | 117 + login/apps/login/tsconfig.json | 24 + login/apps/login/turbo.json | 22 + login/apps/login/vitest.config.mts | 12 + login/docker-bake.hcl | 145 + login/dockerfiles/login-client.Dockerfile | 7 + .../login-client.Dockerfile.dockerignore | 11 + login/dockerfiles/login-dev-base.Dockerfile | 3 + .../login-dev-base.Dockerfile.dockerignore | 1 + login/dockerfiles/login-lint.Dockerfile | 7 + .../login-lint.Dockerfile.dockerignore | 25 + login/dockerfiles/login-pnpm.Dockerfile | 10 + .../login-pnpm.Dockerfile.dockerignore | 6 + login/dockerfiles/login-standalone.Dockerfile | 34 + .../login-standalone.Dockerfile.dockerignore | 17 + .../login-test-acceptance.Dockerfile | 8 + ...in-test-acceptance.Dockerfile.dockerignore | 5 + .../login-test-integration.Dockerfile | 11 + ...n-test-integration.Dockerfile.dockerignore | 9 + login/dockerfiles/login-test-unit.Dockerfile | 6 + .../login-test-unit.Dockerfile.dockerignore | 13 + ...gin-typescript-proto-client-out.Dockerfile | 5 + ...t-proto-client-out.Dockerfile.dockerignore | 1 + login/dockerfiles/proto-files.Dockerfile | 8 + .../proto-files.Dockerfile.dockerignore | 1 + .../typescript-proto-client.Dockerfile | 6 + ...cript-proto-client.Dockerfile.dockerignore | 11 + login/meta.json | 4 + login/package.json | 55 + login/packages/zitadel-client/.eslintrc.cjs | 4 + login/packages/zitadel-client/.gitignore | 4 + login/packages/zitadel-client/CHANGELOG.md | 77 + login/packages/zitadel-client/README.md | 53 + login/packages/zitadel-client/package.json | 71 + login/packages/zitadel-client/src/helpers.ts | 11 + login/packages/zitadel-client/src/index.ts | 10 + .../zitadel-client/src/interceptors.test.ts | 67 + .../zitadel-client/src/interceptors.ts | 16 + login/packages/zitadel-client/src/node.ts | 36 + login/packages/zitadel-client/src/v1.ts | 11 + login/packages/zitadel-client/src/v2.ts | 27 + login/packages/zitadel-client/src/v3alpha.ts | 6 + login/packages/zitadel-client/src/web.ts | 15 + login/packages/zitadel-client/tsconfig.json | 5 + login/packages/zitadel-client/tsup.config.ts | 13 + login/packages/zitadel-client/turbo.json | 12 + .../zitadel-eslint-config/CHANGELOG.md | 13 + .../packages/zitadel-eslint-config/README.md | 35 + login/packages/zitadel-eslint-config/index.js | 13 + .../zitadel-eslint-config/package.json | 17 + .../zitadel-prettier-config/CHANGELOG.md | 13 + .../zitadel-prettier-config/README.md | 36 + .../packages/zitadel-prettier-config/index.js | 11 + .../zitadel-prettier-config/package.json | 12 + login/packages/zitadel-proto/.gitignore | 5 + login/packages/zitadel-proto/CHANGELOG.md | 47 + login/packages/zitadel-proto/README.md | 35 + login/packages/zitadel-proto/buf.gen.yaml | 10 + login/packages/zitadel-proto/package.json | 26 + login/packages/zitadel-proto/turbo.json | 9 + .../zitadel-tailwind-config/CHANGELOG.md | 13 + .../zitadel-tailwind-config/README.md | 36 + .../zitadel-tailwind-config/package.json | 12 + .../tailwind.config.mjs | 97 + login/packages/zitadel-tsconfig/CHANGELOG.md | 13 + login/packages/zitadel-tsconfig/README.md | 35 + login/packages/zitadel-tsconfig/base.json | 20 + login/packages/zitadel-tsconfig/nextjs.json | 32 + login/packages/zitadel-tsconfig/node20.json | 10 + login/packages/zitadel-tsconfig/package.json | 9 + .../zitadel-tsconfig/react-library.json | 11 + login/packages/zitadel-tsconfig/tsup.json | 5 + login/pnpm-lock.yaml | 9519 +++++++++++++++++ login/pnpm-workspace.yaml | 3 + login/scripts/entrypoint.sh | 11 + login/scripts/healthcheck.js | 14 + login/scripts/run_or_skip.sh | 67 + login/turbo.json | 51 + 416 files changed, 38969 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/login-container.yml create mode 100644 .github/workflows/login-quality.yml create mode 100644 build/Dockerfile.gitignore create mode 100644 docker-bake.hcl create mode 100644 dockerfiles/proto-files.Dockerfile create mode 100644 dockerfiles/proto-files.Dockerfile.dockerignore create mode 100644 dockerfiles/typescript-proto-client.Dockerfile create mode 100644 dockerfiles/typescript-proto-client.Dockerfile.dockerignore create mode 100644 login/.changeset/README.md create mode 100644 login/.changeset/config.json create mode 100644 login/.eslintrc.cjs create mode 100644 login/.github/ISSUE_TEMPLATE/bug.yaml create mode 100644 login/.github/ISSUE_TEMPLATE/config.yml create mode 100644 login/.github/ISSUE_TEMPLATE/docs.yaml create mode 100644 login/.github/ISSUE_TEMPLATE/improvement.yaml create mode 100644 login/.github/ISSUE_TEMPLATE/proposal.yaml create mode 100644 login/.github/custom-i18n.png create mode 100644 login/.github/dependabot.yml create mode 100644 login/.github/pull_request_template.md create mode 100644 login/.github/workflows/close_pr.yml create mode 100644 login/.github/workflows/issues.yml create mode 100644 login/.github/workflows/release.yml create mode 100644 login/.github/workflows/test.yml create mode 100644 login/.gitignore create mode 100644 login/.npmrc create mode 100644 login/.nvmrc create mode 100644 login/.prettierignore create mode 100644 login/.prettierrc create mode 100644 login/CODE_OF_CONDUCT.md create mode 100644 login/CONTRIBUTING.md create mode 100644 login/LICENSE create mode 100644 login/Makefile create mode 100644 login/README.md create mode 100644 login/acceptance/docker-compose.yaml create mode 100644 login/apps/login-test-acceptance/.gitignore create mode 100644 login/apps/login-test-acceptance/docker-compose-ci.yaml create mode 100644 login/apps/login-test-acceptance/docker-compose.yaml create mode 100644 login/apps/login-test-acceptance/go-command.Dockerfile create mode 100644 login/apps/login-test-acceptance/idp/oidc/go.mod create mode 100644 login/apps/login-test-acceptance/idp/oidc/go.sum create mode 100644 login/apps/login-test-acceptance/idp/oidc/main.go create mode 100644 login/apps/login-test-acceptance/idp/saml/go.mod create mode 100644 login/apps/login-test-acceptance/idp/saml/go.sum create mode 100644 login/apps/login-test-acceptance/idp/saml/main.go create mode 100644 login/apps/login-test-acceptance/oidcrp/go.mod create mode 100644 login/apps/login-test-acceptance/oidcrp/go.sum create mode 100644 login/apps/login-test-acceptance/oidcrp/main.go create mode 100644 login/apps/login-test-acceptance/package.json create mode 100644 login/apps/login-test-acceptance/pat/.gitignore create mode 100644 login/apps/login-test-acceptance/pat/.gitkeep create mode 100644 login/apps/login-test-acceptance/playwright-report/.gitignore create mode 100644 login/apps/login-test-acceptance/playwright-report/.gitkeep create mode 100644 login/apps/login-test-acceptance/playwright.config.ts create mode 100644 login/apps/login-test-acceptance/samlsp/go.mod create mode 100644 login/apps/login-test-acceptance/samlsp/go.sum create mode 100644 login/apps/login-test-acceptance/samlsp/main.go create mode 100644 login/apps/login-test-acceptance/setup/go.mod create mode 100644 login/apps/login-test-acceptance/setup/go.sum create mode 100644 login/apps/login-test-acceptance/setup/main.go create mode 100755 login/apps/login-test-acceptance/setup/setup.sh create mode 100644 login/apps/login-test-acceptance/sink/go.mod create mode 100644 login/apps/login-test-acceptance/sink/go.sum create mode 100644 login/apps/login-test-acceptance/sink/main.go create mode 100644 login/apps/login-test-acceptance/test-results/.gitignore create mode 100644 login/apps/login-test-acceptance/test-results/.gitkeep create mode 100644 login/apps/login-test-acceptance/tests/admin.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/code-screen.ts create mode 100644 login/apps/login-test-acceptance/tests/code.ts create mode 100644 login/apps/login-test-acceptance/tests/email-verify-screen.ts create mode 100644 login/apps/login-test-acceptance/tests/email-verify.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/email-verify.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-apple.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-generic-jwt.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-generic-oauth.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-generic-oidc.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-github-enterprise.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-github.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-gitlab-self-hosted.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-gitlab.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-google.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-ldap.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-microsoft.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-saml.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/login-configuration-possiblities.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/login.ts create mode 100644 login/apps/login-test-acceptance/tests/loginname-screen.ts create mode 100644 login/apps/login-test-acceptance/tests/loginname.ts create mode 100644 login/apps/login-test-acceptance/tests/passkey.ts create mode 100644 login/apps/login-test-acceptance/tests/password-screen.ts create mode 100644 login/apps/login-test-acceptance/tests/password.ts create mode 100644 login/apps/login-test-acceptance/tests/register-screen.ts create mode 100644 login/apps/login-test-acceptance/tests/register.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/register.ts create mode 100644 login/apps/login-test-acceptance/tests/select-account.ts create mode 100644 login/apps/login-test-acceptance/tests/sink.ts create mode 100644 login/apps/login-test-acceptance/tests/user.ts create mode 100644 login/apps/login-test-acceptance/tests/username-passkey.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-change-required.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-changed.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-otp_email.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-otp_sms.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-set.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-totp.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-u2f.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/welcome.ts create mode 100644 login/apps/login-test-acceptance/tests/zitadel.ts create mode 100644 login/apps/login-test-acceptance/turbo.json create mode 100644 login/apps/login-test-acceptance/zitadel.yaml create mode 100644 login/apps/login-test-integration/.gitignore create mode 100644 login/apps/login-test-integration/core-mock/Dockerfile create mode 100644 login/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json create mode 100644 login/apps/login-test-integration/core-mock/mocked-services.cfg create mode 100644 login/apps/login-test-integration/cypress.config.ts create mode 100644 login/apps/login-test-integration/docker-compose.yaml create mode 100644 login/apps/login-test-integration/fixtures/example.json create mode 100644 login/apps/login-test-integration/integration/invite.cy.ts create mode 100644 login/apps/login-test-integration/integration/login.cy.ts create mode 100644 login/apps/login-test-integration/integration/register-idp.cy.ts create mode 100644 login/apps/login-test-integration/integration/register.cy.ts create mode 100644 login/apps/login-test-integration/integration/verify.cy.ts create mode 100644 login/apps/login-test-integration/package.json create mode 100644 login/apps/login-test-integration/support/e2e.ts create mode 100644 login/apps/login-test-integration/tsconfig.json create mode 100644 login/apps/login-test-integration/turbo.json create mode 100644 login/apps/login/.env.test create mode 100755 login/apps/login/.eslintrc.cjs create mode 100644 login/apps/login/.gitignore create mode 100644 login/apps/login/.prettierignore create mode 100644 login/apps/login/constants/csp.js create mode 100644 login/apps/login/locales/de.json create mode 100644 login/apps/login/locales/en.json create mode 100644 login/apps/login/locales/es.json create mode 100644 login/apps/login/locales/it.json create mode 100644 login/apps/login/locales/pl.json create mode 100644 login/apps/login/locales/ru.json create mode 100644 login/apps/login/locales/zh.json create mode 100644 login/apps/login/next-env-vars.d.ts create mode 100755 login/apps/login/next-env.d.ts create mode 100755 login/apps/login/next.config.mjs create mode 100644 login/apps/login/package.json create mode 100644 login/apps/login/postcss.config.cjs create mode 100644 login/apps/login/prettier.config.mjs create mode 100644 login/apps/login/public/checkbox.svg create mode 100644 login/apps/login/public/favicon.ico create mode 100644 login/apps/login/public/favicon/android-chrome-192x192.png create mode 100644 login/apps/login/public/favicon/android-chrome-512x512.png create mode 100644 login/apps/login/public/favicon/apple-touch-icon.png create mode 100644 login/apps/login/public/favicon/browserconfig.xml create mode 100644 login/apps/login/public/favicon/favicon-16x16.png create mode 100644 login/apps/login/public/favicon/favicon-32x32.png create mode 100644 login/apps/login/public/favicon/favicon.ico create mode 100644 login/apps/login/public/favicon/mstile-150x150.png create mode 100644 login/apps/login/public/favicon/site.webmanifest create mode 100644 login/apps/login/public/grid-dark.svg create mode 100644 login/apps/login/public/grid-light.svg create mode 100644 login/apps/login/public/logo/zitadel-logo-solo-darkdesign.svg create mode 100644 login/apps/login/public/logo/zitadel-logo-solo-lightdesign.svg create mode 100644 login/apps/login/public/zitadel-logo-dark.svg create mode 100644 login/apps/login/public/zitadel-logo-light.svg create mode 100644 login/apps/login/readme.md create mode 100644 login/apps/login/screenshots/accounts.png create mode 100644 login/apps/login/screenshots/accounts_jumpto.png create mode 100644 login/apps/login/screenshots/collage.png create mode 100644 login/apps/login/screenshots/idp.png create mode 100644 login/apps/login/screenshots/loginname.png create mode 100644 login/apps/login/screenshots/mfa.png create mode 100644 login/apps/login/screenshots/mfaset.png create mode 100644 login/apps/login/screenshots/otp.png create mode 100644 login/apps/login/screenshots/otpset.png create mode 100644 login/apps/login/screenshots/passkey.png create mode 100644 login/apps/login/screenshots/password.png create mode 100644 login/apps/login/screenshots/password_change.png create mode 100644 login/apps/login/screenshots/password_set.png create mode 100644 login/apps/login/screenshots/register.png create mode 100644 login/apps/login/screenshots/register_password.png create mode 100644 login/apps/login/screenshots/signedin.png create mode 100644 login/apps/login/screenshots/u2f.png create mode 100644 login/apps/login/screenshots/u2fset.png create mode 100644 login/apps/login/screenshots/verify.png create mode 100644 login/apps/login/src/app/(login)/accounts/page.tsx create mode 100644 login/apps/login/src/app/(login)/authenticator/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/device/consent/page.tsx create mode 100644 login/apps/login/src/app/(login)/device/page.tsx create mode 100644 login/apps/login/src/app/(login)/error.tsx create mode 100644 login/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx create mode 100644 login/apps/login/src/app/(login)/idp/[provider]/success/page.tsx create mode 100644 login/apps/login/src/app/(login)/idp/ldap/page.tsx create mode 100644 login/apps/login/src/app/(login)/idp/page.tsx create mode 100644 login/apps/login/src/app/(login)/layout.tsx create mode 100644 login/apps/login/src/app/(login)/loginname/page.tsx create mode 100644 login/apps/login/src/app/(login)/logout/page.tsx create mode 100644 login/apps/login/src/app/(login)/logout/success/page.tsx create mode 100644 login/apps/login/src/app/(login)/mfa/page.tsx create mode 100644 login/apps/login/src/app/(login)/mfa/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/otp/[method]/page.tsx create mode 100644 login/apps/login/src/app/(login)/otp/[method]/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/page.tsx create mode 100644 login/apps/login/src/app/(login)/passkey/page.tsx create mode 100644 login/apps/login/src/app/(login)/passkey/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/password/change/page.tsx create mode 100644 login/apps/login/src/app/(login)/password/page.tsx create mode 100644 login/apps/login/src/app/(login)/password/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/register/page.tsx create mode 100644 login/apps/login/src/app/(login)/register/password/page.tsx create mode 100644 login/apps/login/src/app/(login)/saml-post/route.ts create mode 100644 login/apps/login/src/app/(login)/signedin/page.tsx create mode 100644 login/apps/login/src/app/(login)/u2f/page.tsx create mode 100644 login/apps/login/src/app/(login)/u2f/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/verify/page.tsx create mode 100644 login/apps/login/src/app/(login)/verify/success/page.tsx create mode 100644 login/apps/login/src/app/global-error.tsx create mode 100644 login/apps/login/src/app/healthy/route.ts create mode 100644 login/apps/login/src/app/login/route.ts create mode 100644 login/apps/login/src/app/security/route.ts create mode 100644 login/apps/login/src/components/address-bar.tsx create mode 100644 login/apps/login/src/components/alert.tsx create mode 100644 login/apps/login/src/components/app-avatar.tsx create mode 100644 login/apps/login/src/components/auth-methods.tsx create mode 100644 login/apps/login/src/components/authentication-method-radio.tsx create mode 100644 login/apps/login/src/components/avatar.tsx create mode 100644 login/apps/login/src/components/back-button.tsx create mode 100644 login/apps/login/src/components/boundary.tsx create mode 100644 login/apps/login/src/components/button.tsx create mode 100644 login/apps/login/src/components/change-password-form.tsx create mode 100644 login/apps/login/src/components/checkbox.tsx create mode 100644 login/apps/login/src/components/choose-authenticator-to-login.tsx create mode 100644 login/apps/login/src/components/choose-authenticator-to-setup.tsx create mode 100644 login/apps/login/src/components/choose-second-factor-to-setup.tsx create mode 100644 login/apps/login/src/components/choose-second-factor.tsx create mode 100644 login/apps/login/src/components/consent.tsx create mode 100644 login/apps/login/src/components/copy-to-clipboard.tsx create mode 100644 login/apps/login/src/components/default-tags.tsx create mode 100644 login/apps/login/src/components/device-code-form.tsx create mode 100644 login/apps/login/src/components/dynamic-theme.tsx create mode 100644 login/apps/login/src/components/external-link.tsx create mode 100644 login/apps/login/src/components/idp-signin.tsx create mode 100644 login/apps/login/src/components/idps/base-button.tsx create mode 100644 login/apps/login/src/components/idps/pages/complete-idp.tsx create mode 100644 login/apps/login/src/components/idps/pages/linking-failed.tsx create mode 100644 login/apps/login/src/components/idps/pages/linking-success.tsx create mode 100644 login/apps/login/src/components/idps/pages/login-failed.tsx create mode 100644 login/apps/login/src/components/idps/pages/login-success.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-apple.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-azure-ad.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-generic.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-github.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-gitlab.test.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-gitlab.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-google.test.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-google.tsx create mode 100644 login/apps/login/src/components/input.tsx create mode 100644 login/apps/login/src/components/language-provider.tsx create mode 100644 login/apps/login/src/components/language-switcher.tsx create mode 100644 login/apps/login/src/components/layout-providers.tsx create mode 100644 login/apps/login/src/components/ldap-username-password-form.tsx create mode 100644 login/apps/login/src/components/login-otp.tsx create mode 100644 login/apps/login/src/components/login-passkey.tsx create mode 100644 login/apps/login/src/components/logo.tsx create mode 100644 login/apps/login/src/components/password-complexity.test.tsx create mode 100644 login/apps/login/src/components/password-complexity.tsx create mode 100644 login/apps/login/src/components/password-form.tsx create mode 100644 login/apps/login/src/components/privacy-policy-checkboxes.tsx create mode 100644 login/apps/login/src/components/register-form-idp-incomplete.tsx create mode 100644 login/apps/login/src/components/register-form.tsx create mode 100644 login/apps/login/src/components/register-passkey.tsx create mode 100644 login/apps/login/src/components/register-u2f.tsx create mode 100644 login/apps/login/src/components/self-service-menu.tsx create mode 100644 login/apps/login/src/components/session-clear-item.tsx create mode 100644 login/apps/login/src/components/session-item.tsx create mode 100644 login/apps/login/src/components/sessions-clear-list.tsx create mode 100644 login/apps/login/src/components/sessions-list.tsx create mode 100644 login/apps/login/src/components/set-password-form.tsx create mode 100644 login/apps/login/src/components/set-register-password-form.tsx create mode 100644 login/apps/login/src/components/sign-in-with-idp.tsx create mode 100644 login/apps/login/src/components/skeleton-card.tsx create mode 100644 login/apps/login/src/components/skeleton.tsx create mode 100644 login/apps/login/src/components/spinner.tsx create mode 100644 login/apps/login/src/components/state-badge.tsx create mode 100644 login/apps/login/src/components/tab-group.tsx create mode 100644 login/apps/login/src/components/tab.tsx create mode 100644 login/apps/login/src/components/theme-provider.tsx create mode 100644 login/apps/login/src/components/theme-wrapper.tsx create mode 100644 login/apps/login/src/components/theme.tsx create mode 100644 login/apps/login/src/components/totp-register.tsx create mode 100644 login/apps/login/src/components/translated.tsx create mode 100644 login/apps/login/src/components/user-avatar.tsx create mode 100644 login/apps/login/src/components/username-form.tsx create mode 100644 login/apps/login/src/components/verify-form.tsx create mode 100644 login/apps/login/src/components/zitadel-logo-dark.tsx create mode 100644 login/apps/login/src/components/zitadel-logo-light.tsx create mode 100644 login/apps/login/src/components/zitadel-logo.tsx create mode 100644 login/apps/login/src/helpers/base64.ts create mode 100644 login/apps/login/src/helpers/colors.ts create mode 100644 login/apps/login/src/helpers/validators.ts create mode 100644 login/apps/login/src/i18n/request.ts create mode 100644 login/apps/login/src/lib/api.ts create mode 100644 login/apps/login/src/lib/client.ts create mode 100644 login/apps/login/src/lib/cookies.ts create mode 100644 login/apps/login/src/lib/demos.ts create mode 100644 login/apps/login/src/lib/fingerprint.ts create mode 100644 login/apps/login/src/lib/hooks.ts create mode 100644 login/apps/login/src/lib/i18n.ts create mode 100644 login/apps/login/src/lib/idp.ts create mode 100644 login/apps/login/src/lib/oidc.ts create mode 100644 login/apps/login/src/lib/saml.ts create mode 100644 login/apps/login/src/lib/self.ts create mode 100644 login/apps/login/src/lib/server/cookie.ts create mode 100644 login/apps/login/src/lib/server/device.ts create mode 100644 login/apps/login/src/lib/server/idp.ts create mode 100644 login/apps/login/src/lib/server/loginname.ts create mode 100644 login/apps/login/src/lib/server/oidc.ts create mode 100644 login/apps/login/src/lib/server/otp.ts create mode 100644 login/apps/login/src/lib/server/passkeys.ts create mode 100644 login/apps/login/src/lib/server/password.ts create mode 100644 login/apps/login/src/lib/server/register.ts create mode 100644 login/apps/login/src/lib/server/session.ts create mode 100644 login/apps/login/src/lib/server/u2f.ts create mode 100644 login/apps/login/src/lib/server/verify.ts create mode 100644 login/apps/login/src/lib/service-url.ts create mode 100644 login/apps/login/src/lib/service.ts create mode 100644 login/apps/login/src/lib/session.ts create mode 100644 login/apps/login/src/lib/verify-helper.ts create mode 100644 login/apps/login/src/lib/zitadel.ts create mode 100644 login/apps/login/src/middleware.ts create mode 100755 login/apps/login/src/styles/globals.scss create mode 100644 login/apps/login/src/styles/vars.scss create mode 100644 login/apps/login/tailwind.config.mjs create mode 100755 login/apps/login/tsconfig.json create mode 100644 login/apps/login/turbo.json create mode 100644 login/apps/login/vitest.config.mts create mode 100644 login/docker-bake.hcl create mode 100644 login/dockerfiles/login-client.Dockerfile create mode 100644 login/dockerfiles/login-client.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-dev-base.Dockerfile create mode 100644 login/dockerfiles/login-dev-base.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-lint.Dockerfile create mode 100644 login/dockerfiles/login-lint.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-pnpm.Dockerfile create mode 100644 login/dockerfiles/login-pnpm.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-standalone.Dockerfile create mode 100644 login/dockerfiles/login-standalone.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-test-acceptance.Dockerfile create mode 100644 login/dockerfiles/login-test-acceptance.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-test-integration.Dockerfile create mode 100644 login/dockerfiles/login-test-integration.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-test-unit.Dockerfile create mode 100644 login/dockerfiles/login-test-unit.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-typescript-proto-client-out.Dockerfile create mode 100644 login/dockerfiles/login-typescript-proto-client-out.Dockerfile.dockerignore create mode 100644 login/dockerfiles/proto-files.Dockerfile create mode 100644 login/dockerfiles/proto-files.Dockerfile.dockerignore create mode 100644 login/dockerfiles/typescript-proto-client.Dockerfile create mode 100644 login/dockerfiles/typescript-proto-client.Dockerfile.dockerignore create mode 100644 login/meta.json create mode 100644 login/package.json create mode 100644 login/packages/zitadel-client/.eslintrc.cjs create mode 100644 login/packages/zitadel-client/.gitignore create mode 100644 login/packages/zitadel-client/CHANGELOG.md create mode 100644 login/packages/zitadel-client/README.md create mode 100644 login/packages/zitadel-client/package.json create mode 100644 login/packages/zitadel-client/src/helpers.ts create mode 100644 login/packages/zitadel-client/src/index.ts create mode 100644 login/packages/zitadel-client/src/interceptors.test.ts create mode 100644 login/packages/zitadel-client/src/interceptors.ts create mode 100644 login/packages/zitadel-client/src/node.ts create mode 100644 login/packages/zitadel-client/src/v1.ts create mode 100644 login/packages/zitadel-client/src/v2.ts create mode 100644 login/packages/zitadel-client/src/v3alpha.ts create mode 100644 login/packages/zitadel-client/src/web.ts create mode 100644 login/packages/zitadel-client/tsconfig.json create mode 100644 login/packages/zitadel-client/tsup.config.ts create mode 100644 login/packages/zitadel-client/turbo.json create mode 100644 login/packages/zitadel-eslint-config/CHANGELOG.md create mode 100644 login/packages/zitadel-eslint-config/README.md create mode 100644 login/packages/zitadel-eslint-config/index.js create mode 100644 login/packages/zitadel-eslint-config/package.json create mode 100644 login/packages/zitadel-prettier-config/CHANGELOG.md create mode 100644 login/packages/zitadel-prettier-config/README.md create mode 100644 login/packages/zitadel-prettier-config/index.js create mode 100644 login/packages/zitadel-prettier-config/package.json create mode 100644 login/packages/zitadel-proto/.gitignore create mode 100644 login/packages/zitadel-proto/CHANGELOG.md create mode 100644 login/packages/zitadel-proto/README.md create mode 100644 login/packages/zitadel-proto/buf.gen.yaml create mode 100644 login/packages/zitadel-proto/package.json create mode 100644 login/packages/zitadel-proto/turbo.json create mode 100644 login/packages/zitadel-tailwind-config/CHANGELOG.md create mode 100644 login/packages/zitadel-tailwind-config/README.md create mode 100644 login/packages/zitadel-tailwind-config/package.json create mode 100644 login/packages/zitadel-tailwind-config/tailwind.config.mjs create mode 100644 login/packages/zitadel-tsconfig/CHANGELOG.md create mode 100644 login/packages/zitadel-tsconfig/README.md create mode 100644 login/packages/zitadel-tsconfig/base.json create mode 100644 login/packages/zitadel-tsconfig/nextjs.json create mode 100644 login/packages/zitadel-tsconfig/node20.json create mode 100644 login/packages/zitadel-tsconfig/package.json create mode 100644 login/packages/zitadel-tsconfig/react-library.json create mode 100644 login/packages/zitadel-tsconfig/tsup.json create mode 100644 login/pnpm-lock.yaml create mode 100644 login/pnpm-workspace.yaml create mode 100755 login/scripts/entrypoint.sh create mode 100644 login/scripts/healthcheck.js create mode 100755 login/scripts/run_or_skip.sh create mode 100644 login/turbo.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b7354f3f4a..8fa71ba652 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,6 +22,19 @@ updates: commit-message: prefix: chore include: scope +- package-ecosystem: npm + directory: '/login' + open-pull-requests-limit: 3 + schedule: + interval: daily + groups: + prod: + dependency-type: production + dev: + dependency-type: development + ignore: + - dependency-name: "eslint" + versions: [ "9.x" ] - package-ecosystem: gomod groups: go: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f06c4a959c..47aa4adef0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,6 +18,8 @@ permissions: packages: write issues: write pull-requests: write + actions: write + id-token: write jobs: core: @@ -47,6 +49,7 @@ jobs: core_cache_path: ${{ needs.core.outputs.cache_path }} console_cache_path: ${{ needs.console.outputs.cache_path }} version: ${{ needs.version.outputs.version }} + node_version: "20" core-unit-test: needs: core @@ -76,6 +79,16 @@ jobs: core_cache_key: ${{ needs.core.outputs.cache_key }} core_cache_path: ${{ needs.core.outputs.cache_path }} + login-quality: + needs: [compile] + uses: ./.github/workflows/login-quality.yml + permissions: + actions: write + id-token: write + with: + ignore-run-cache: ${{ github.event_name == 'workflow_dispatch' }} + node_version: "20" + container: needs: [compile] uses: ./.github/workflows/container.yml @@ -86,6 +99,16 @@ jobs: with: build_image_name: "ghcr.io/zitadel/zitadel-build" + login-container: + uses: ./.github/workflows/login-container.yml + if: ${{ github.event_name == 'workflow_dispatch' }} + permissions: + packages: write + id-token: write + with: + login_build_image_name: "ghcr.io/zitadel/login-build" + node_version: "20" + e2e: uses: ./.github/workflows/e2e.yml needs: [compile] @@ -98,7 +121,7 @@ jobs: issues: write pull-requests: write needs: - [version, core-unit-test, core-integration-test, lint, container, e2e] + [version, core-unit-test, core-integration-test, lint, container, login-container, login-quality, e2e] if: ${{ github.event_name == 'workflow_dispatch' }} secrets: GCR_JSON_KEY_BASE64: ${{ secrets.GCR_JSON_KEY_BASE64 }} @@ -109,3 +132,6 @@ jobs: semantic_version: "23.0.7" image_name: "ghcr.io/zitadel/zitadel" google_image_name: "europe-docker.pkg.dev/zitadel-common/zitadel-repo/zitadel" + build_image_name_login: ${{ needs.login-container.outputs.login_build_image }} + image_name_login: "ghcr.io/zitadel/login" + google_image_name_login: europe-docker.pkg.dev/zitadel-common/zitadel-repo/login diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index 519586b9ee..7b64427a18 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -18,7 +18,9 @@ on: version: required: true type: string - + node_version: + required: true + type: string jobs: executable: runs-on: ubuntu-latest @@ -73,10 +75,38 @@ jobs: with: name: zitadel-${{ matrix.goos }}-${{ matrix.goarch }} path: zitadel-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz - + + login: + runs-on: ubuntu-latest + steps: + - + uses: actions/checkout@v4 + - + uses: depot/setup-action@v1 + with: + oidc: true + - + run: make login_standalone_out + env: + # latest if branch is main, otherwise image version which is the pull request number + LOGIN_BAKE_CLI: depot bake + DEPOT_PROJECT_ID: w47wkxzdtw + NODE_VERSION: ${{ inputs.node_version }} + - + name: move files + run: | + cp login/LICENSE login/apps/login/standalone/ + cp login/README.md login/apps/login/standalone/ + tar -czvf login.tar.gz -C login/apps/login/standalone . + - + uses: actions/upload-artifact@v4 + with: + name: login + path: login.tar.gz + checksums: runs-on: ubuntu-latest - needs: executable + needs: [executable, login] steps: - uses: actions/download-artifact@v4 diff --git a/.github/workflows/login-container.yml b/.github/workflows/login-container.yml new file mode 100644 index 0000000000..bce15512af --- /dev/null +++ b/.github/workflows/login-container.yml @@ -0,0 +1,63 @@ +name: Login Container + +on: + workflow_call: + inputs: + login_build_image_name: + description: 'The image repository name of the standalone login image' + type: string + required: true + node_version: + required: true + type: string + outputs: + login_build_image: + description: 'The full image tag of the standalone login image' + value: '${{ inputs.login_build_image_name }}:${{ github.sha }}' + +permissions: + packages: write + +env: + default_labels: | + org.opencontainers.image.documentation=https://zitadel.com/docs + org.opencontainers.image.vendor=CAOS AG + +jobs: + login-container: + name: Build Login Container + runs-on: depot-ubuntu-22.04-8 + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: depot/setup-action@v1 + with: + oidc: true + - name: Login meta + id: login-meta + uses: docker/metadata-action@v5 + with: + images: ${{ inputs.login_build_image_name }} + labels: ${{ env.default_labels}} + tags: | + type=sha,prefix=,suffix=,format=long + - name: Login to Docker registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Bake login multi-arch + uses: depot/bake-action@v1 + env: + NODE_VERSION: ${{ inputs.node_version }} + with: + workdir: login + push: true + targets: login-standalone + set: login-standalone.platforms=[linux/amd64,linux/arm64] + project: w47wkxzdtw + files: | + ./docker-bake.hcl + cwd://${{ steps.login-meta.outputs.bake-file }} diff --git a/.github/workflows/login-quality.yml b/.github/workflows/login-quality.yml new file mode 100644 index 0000000000..0b4fea73f4 --- /dev/null +++ b/.github/workflows/login-quality.yml @@ -0,0 +1,59 @@ +name: Login Quality + +on: + workflow_call: + inputs: + ignore-run-cache: + description: 'Ignore run caches' + type: boolean + required: true + node_version: + required: true + type: string +jobs: + quality: + name: Ensure Quality + runs-on: depot-ubuntu-22.04-8 + timeout-minutes: 30 + permissions: + id-token: write + actions: write + env: + CACHE_DIR: /tmp/login-run-caches + steps: + - uses: actions/checkout@v4 + - uses: depot/setup-action@v1 + with: + oidc: true + - name: Restore Run Caches + uses: actions/cache/restore@v4 + id: run-caches-restore + with: + path: ${{ env.CACHE_DIR }} + key: ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}-${{github.run_attempt}} + restore-keys: | + ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}- + ${{ runner.os }}-login-run-caches-${{github.ref_name}}- + ${{ runner.os }}-login-run-caches- + - uses: actions/download-artifact@v4 + with: + path: .artifacts + name: zitadel-linux-amd64 + - name: Unpack executable + run: | + tar -xvf .artifacts/zitadel-linux-amd64.tar.gz + mv zitadel-linux-amd64/zitadel ./zitadel + - run: make login_quality + env: + # latest if branch is main, otherwise image version which is the pull request number + LOGIN_BAKE_CLI: depot bake + DEPOT_PROJECT_ID: w47wkxzdtw + IGNORE_RUN_CACHE: ${{ github.event.inputs.ignore-run-cache }} + NODE_VERSION: ${{ inputs.node_version }} + + - name: Save Run Caches + uses: actions/cache/save@v4 + with: + path: ${{ env.CACHE_DIR }} + key: ${{ steps.run-caches-restore.outputs.cache-primary-key }} + if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e40ae8805..e23c8869c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,15 @@ on: google_image_name: required: true type: string + build_image_name_login: + required: true + type: string + image_name_login: + required: true + type: string + google_image_name_login: + required: true + type: string secrets: GCR_JSON_KEY_BASE64: description: 'base64 endcrypted key to connect to Google' @@ -96,6 +105,12 @@ jobs: docker buildx imagetools create \ --tag ${{ inputs.google_image_name }}:${{ needs.version.outputs.version }} \ ${{ inputs.build_image_name }} + docker buildx imagetools create \ + --tag ${{ inputs.image_name_login }}:${{ needs.version.outputs.version }} \ + ${{ inputs.build_image_name_login }} + docker buildx imagetools create \ + --tag ${{ inputs.google_image_name_login }}:${{ needs.version.outputs.version }} \ + ${{ inputs.build_image_name_login }} - name: Publish latest if: ${{ github.ref_name == 'next' }} @@ -106,6 +121,9 @@ jobs: docker buildx imagetools create \ --tag ${{ inputs.image_name }}:latest-debug \ ${{ inputs.build_image_name }}-debug + docker buildx imagetools create \ + --tag ${{ inputs.image_name_login }}:latest \ + ${{ inputs.build_image_name_login }} homebrew-tap: runs-on: ubuntu-22.04 @@ -146,3 +164,55 @@ jobs: GH_TOKEN: ${{ steps.generate-token.outputs.token }} run: | gh workflow -R zitadel/zitadel-charts run bump.yml + + typescript-packages: + runs-on: ubuntu-latest + needs: version + if: ${{ github.ref_name == 'next' }} + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + working-directory: login + run: pnpm install + + - name: Create Release Pull Request + uses: changesets/action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + version: ${{ needs.version.outputs.version }} + cwd: login + + typescript-repo: + runs-on: ubuntu-latest + needs: version + if: ${{ github.ref_name == 'next' }} + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Push Subtree + run: make login_push LOGIN_REMOTE_BRANCH=mirror-zitadel-repo + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: mirror zitadel repo' + branch: mirror-zitadel-repo + title: 'chore: mirror zitadel repo' + body: 'This PR updates the login repository with the latest changes from the zitadel repository.' + base: main + reviewers: | + @peintnermax + @eliobischof diff --git a/.golangci.yaml b/.golangci.yaml index 1cae359605..a4d5fd95d4 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -20,6 +20,7 @@ issues: - openapi - proto - tools + - login run: concurrency: 4 diff --git a/LICENSING.md b/LICENSING.md index 9cad2082f8..259a0d5070 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -18,6 +18,13 @@ The following files and directories, including their subdirectories, are license proto/ ``` + +The following files and directories, including their subdirectories, are licensed under the [MIT License](https://opensource.org/license/mit/): + +``` +login/ +``` + ## Community Contributions To maintain a clear licensing structure and facilitate community contributions, all contributions must be licensed under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) to be accepted. By submitting a contribution, you agree to this licensing. diff --git a/Makefile b/Makefile index 3c50231bee..10f52b7c4c 100644 --- a/Makefile +++ b/Makefile @@ -12,11 +12,21 @@ ZITADEL_MASTERKEY ?= MasterkeyNeedsToHave32Characters export GOCOVERDIR ZITADEL_MASTERKEY +LOGIN_REMOTE_NAME := login +LOGIN_REMOTE_URL ?= https://github.com/zitadel/typescript.git +LOGIN_REMOTE_BRANCH ?= main + .PHONY: compile compile: core_build console_build compile_pipeline .PHONY: docker_image -docker_image: compile +docker_image: + @if [ ! -f ./zitadel ]; then \ + echo "Compiling zitadel binary"; \ + $(MAKE) compile; \ + else \ + echo "Reusing precompiled zitadel binary"; \ + fi DOCKER_BUILDKIT=1 docker build -f build/Dockerfile -t $(ZITADEL_IMAGE) . .PHONY: compile_pipeline @@ -165,3 +175,41 @@ core_lint: --config ./.golangci.yaml \ --out-format=github-actions \ --concurrency=$$(getconf _NPROCESSORS_ONLN) + +.PHONY: login_pull +login_pull: login_ensure_remote + @echo "Pulling changes from the 'login' subtree on remote $(LOGIN_REMOTE_NAME) branch $(LOGIN_REMOTE_BRANCH)" + git fetch $(LOGIN_REMOTE_NAME) + git subtree pull --prefix=login $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_BRANCH) + +.PHONY: login_push +login_push: login_ensure_remote + @echo "Pushing changes to the 'login' subtree on remote $(LOGIN_REMOTE_NAME) branch $(LOGIN_REMOTE_BRANCH)" + git subtree push --prefix=login $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_BRANCH) + +login_ensure_remote: + @if ! git remote get-url $(LOGIN_REMOTE_NAME) > /dev/null 2>&1; then \ + echo "Adding remote $(LOGIN_REMOTE_NAME)"; \ + git remote add $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_URL); \ + else \ + echo "Remote $(LOGIN_REMOTE_NAME) already exists."; \ + fi + @if [ ! -d login ]; then \ + echo "Adding subtree for 'login' from branch $(LOGIN_REMOTE_BRANCH)"; \ + git subtree add --prefix=login $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_BRANCH); \ + else \ + echo "Subtree 'login' already exists."; \ + fi + +export LOGIN_DIR := ./login/ +export LOGIN_BAKE_CLI_ADDITIONAL_ARGS := --set login-*.context=./login/ --file ./docker-bake.hcl +export ZITADEL_TAG ?= $(ZITADEL_IMAGE) +include login/Makefile + +# Intentional override of login_test_acceptance_build +login_test_acceptance_build: docker_image + @echo "Building login test acceptance environment with the local zitadel image" + $(MAKE) login_test_acceptance_build_compose login_test_acceptance_build_bake + +login_dev: docker_image typescript_generate login_test_acceptance_build_compose login_test_acceptance_cleanup login_test_acceptance_setup_dev + @echo "Starting login test environment with the local zitadel image" diff --git a/build/Dockerfile.gitignore b/build/Dockerfile.gitignore new file mode 100644 index 0000000000..a2cc8ed480 --- /dev/null +++ b/build/Dockerfile.gitignore @@ -0,0 +1,3 @@ +* +!build/entrypoint.sh +!zitadel diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 7bb44b743f..9697e354c5 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -526,13 +526,13 @@ OIDC: CharSet: "BCDFGHJKLMNPQRSTVWXZ" # ZITADEL_OIDC_DEVICEAUTH_USERCODE_CHARSET CharAmount: 8 # ZITADEL_OIDC_DEVICEAUTH_USERCODE_CHARARMOUNT DashInterval: 4 # ZITADEL_OIDC_DEVICEAUTH_USERCODE_DASHINTERVAL - DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 - DefaultLogoutURLV2: "/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 + DefaultLogoutURLV2: "/ui/v2/login/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 PublicKeyCacheMaxAge: 24h # ZITADEL_OIDC_PUBLICKEYCACHEMAXAGE DefaultBackChannelLogoutLifetime: 15m # ZITADEL_OIDC_DEFAULTBACKCHANNELLOGOUTLIFETIME SAML: - DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 + DefaultLoginURLV2: "/ui/v2/login/login?samlRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 ProviderConfig: MetadataConfig: Path: "/metadata" # ZITADEL_SAML_PROVIDERCONFIG_METADATACONFIG_PATH @@ -1131,8 +1131,8 @@ DefaultInstance: # OIDCSingleV1SessionTermination: false # ZITADEL_DEFAULTINSTANCE_FEATURES_OIDCSINGLEV1SESSIONTERMINATION # DisableUserTokenEvent: false # ZITADEL_DEFAULTINSTANCE_FEATURES_DISABLEUSERTOKENEVENT # EnableBackChannelLogout: false # ZITADEL_DEFAULTINSTANCE_FEATURES_ENABLEBACKCHANNELLOGOUT - # LoginV2: - # Required: false # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED + LoginV2: + Required: true # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED # BaseURI: "" # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI # PermissionCheckV2: false # ZITADEL_DEFAULTINSTANCE_FEATURES_PERMISSIONCHECKV2 # ConsoleUseV2UserApi: false # ZITADEL_DEFAULTINSTANCE_FEATURES_CONSOLEUSEV2USERAPI diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 0000000000..d75373dee1 --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,5 @@ +target "typescript-proto-client" { + contexts = { + proto-files = "target:proto-files" + } +} diff --git a/dockerfiles/proto-files.Dockerfile b/dockerfiles/proto-files.Dockerfile new file mode 100644 index 0000000000..0af3346096 --- /dev/null +++ b/dockerfiles/proto-files.Dockerfile @@ -0,0 +1,8 @@ +FROM bufbuild/buf:1.54.0 AS proto-files +RUN buf export https://github.com/envoyproxy/protoc-gen-validate.git --path validate --output /proto-files && \ + buf export https://github.com/grpc-ecosystem/grpc-gateway.git --path protoc-gen-openapiv2 --output /proto-files && \ + buf export https://github.com/googleapis/googleapis.git --path google/api/annotations.proto --path google/api/http.proto --path google/api/field_behavior.proto --output /proto-files + +FROM scratch +COPY --from=proto-files /proto-files / +COPY ./proto / diff --git a/dockerfiles/proto-files.Dockerfile.dockerignore b/dockerfiles/proto-files.Dockerfile.dockerignore new file mode 100644 index 0000000000..e26cd3c2d6 --- /dev/null +++ b/dockerfiles/proto-files.Dockerfile.dockerignore @@ -0,0 +1,2 @@ +* +!proto diff --git a/dockerfiles/typescript-proto-client.Dockerfile b/dockerfiles/typescript-proto-client.Dockerfile new file mode 100644 index 0000000000..4a9505d19d --- /dev/null +++ b/dockerfiles/typescript-proto-client.Dockerfile @@ -0,0 +1,8 @@ +FROM login-pnpm AS typescript-proto-client +COPY ./login/packages/zitadel-proto/package.json ./packages/zitadel-proto/ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --workspace-root --filter zitadel-proto +COPY --from=proto-files /buf.yaml /buf.lock /proto-files/ +COPY --from=proto-files /zitadel /proto-files/zitadel +COPY ./login/packages/zitadel-proto/buf.gen.yaml ./packages/zitadel-proto/ +RUN cd packages/zitadel-proto && pnpm exec buf generate /proto-files diff --git a/dockerfiles/typescript-proto-client.Dockerfile.dockerignore b/dockerfiles/typescript-proto-client.Dockerfile.dockerignore new file mode 100644 index 0000000000..3915a26e4e --- /dev/null +++ b/dockerfiles/typescript-proto-client.Dockerfile.dockerignore @@ -0,0 +1,11 @@ +* +!/login/packages/zitadel-proto/ +login/packages/zitadel-proto/google +login/packages/zitadel-proto/zitadel +login/packages/zitadel-proto/protoc-gen-openapiv2 +login/packages/zitadel-proto/validate + +**/*.md +**/*.png +**/node_modules +**/.turbo diff --git a/e2e/config/host.docker.internal/zitadel.yaml b/e2e/config/host.docker.internal/zitadel.yaml index 203dd16437..23f35302b4 100644 --- a/e2e/config/host.docker.internal/zitadel.yaml +++ b/e2e/config/host.docker.internal/zitadel.yaml @@ -60,6 +60,9 @@ Projections: DefaultInstance: LoginPolicy: MfaInitSkipLifetime: "0" + Features: + LoginV2: + Required: false SystemAPIUsers: - cypress: diff --git a/e2e/config/localhost/zitadel.yaml b/e2e/config/localhost/zitadel.yaml index 966bb4f6b7..701e7b806b 100644 --- a/e2e/config/localhost/zitadel.yaml +++ b/e2e/config/localhost/zitadel.yaml @@ -52,6 +52,9 @@ Quotas: DefaultInstance: LoginPolicy: MfaInitSkipLifetime: "0" + Features: + LoginV2: + Required: false SystemAPIUsers: - cypress: diff --git a/internal/integration/config/zitadel.yaml b/internal/integration/config/zitadel.yaml index bb8d86376d..fed746d823 100644 --- a/internal/integration/config/zitadel.yaml +++ b/internal/integration/config/zitadel.yaml @@ -101,3 +101,10 @@ SystemDefaults: KeyConfig: PrivateKeyLifetime: 7200h PublicKeyLifetime: 14400h + +OIDC: + DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 + DefaultLogoutURLV2: "/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 + +SAML: + DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 diff --git a/login/.changeset/README.md b/login/.changeset/README.md new file mode 100644 index 0000000000..e5b6d8d6a6 --- /dev/null +++ b/login/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/login/.changeset/config.json b/login/.changeset/config.json new file mode 100644 index 0000000000..3f2d313f66 --- /dev/null +++ b/login/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": ["@zitadel/login"] +} diff --git a/login/.eslintrc.cjs b/login/.eslintrc.cjs new file mode 100644 index 0000000000..1bfcec169d --- /dev/null +++ b/login/.eslintrc.cjs @@ -0,0 +1,10 @@ +module.exports = { + root: true, + // This tells ESLint to load the config from the package `@zitadel/eslint-config` + extends: ["@zitadel/eslint-config"], + settings: { + next: { + rootDir: ["apps/*/"], + }, + }, +}; diff --git a/login/.github/ISSUE_TEMPLATE/bug.yaml b/login/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 0000000000..2764c1a365 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,63 @@ +name: 🐛 Bug Report +description: "Create a bug report to help us improve ZITADEL Typescript Library." +title: "[Bug]: " +labels: ["bug"] +body: +- type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! +- type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the documentation, the existing issues or discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + validations: + required: true +- type: input + id: version + attributes: + label: Version + description: Which version of ZITADEL Typescript Library are you using. +- type: textarea + id: impact + attributes: + label: Describe the problem caused by this bug + description: A clear and concise description of the problem you have and what the bug is. + validations: + required: true +- type: textarea + id: reproduce + attributes: + label: To reproduce + description: Steps to reproduce the behaviour + placeholder: | + Steps to reproduce the behavior: + 1. ... + validations: + required: true +- type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain your problem. +- type: textarea + id: expected + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen. +- type: textarea + id: config + attributes: + label: Relevant Configuration + description: Add any relevant configurations that could help us. Make sure to redact any sensitive information. +- type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/ISSUE_TEMPLATE/config.yml b/login/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..7e690b9344 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,4 @@ +blank_issues_enabled: true +contact_links: + - name: 💬 ZITADEL Community Chat + url: https://zitadel.com/chat diff --git a/login/.github/ISSUE_TEMPLATE/docs.yaml b/login/.github/ISSUE_TEMPLATE/docs.yaml new file mode 100644 index 0000000000..04c1c0cdb1 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/docs.yaml @@ -0,0 +1,30 @@ +name: 📄 Documentation +description: Create an issue for missing or wrong documentation. +labels: ["docs"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this issue. + - type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the existing issues, docs, nor discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + - type: textarea + id: docs + attributes: + label: Describe the docs your are missing or that are wrong + placeholder: As a [type of user], I want [some goal] so that [some reason]. + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/ISSUE_TEMPLATE/improvement.yaml b/login/.github/ISSUE_TEMPLATE/improvement.yaml new file mode 100644 index 0000000000..cfe79d407b --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/improvement.yaml @@ -0,0 +1,54 @@ +name: 🛠️ Improvement +description: "Create an new issue for an improvment in ZITADEL" +labels: ["improvement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this improvement request + - type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the existing issues, docs, nor discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + - type: textarea + id: problem + attributes: + label: Describe your problem + description: Please describe your problem this improvement is supposed to solve. + placeholder: Describe the problem you have + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe your ideal solution + description: Which solution do you propose? + placeholder: As a [type of user], I want [some goal] so that [some reason]. + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: Which version of the typescript library are you using. + - type: dropdown + id: environment + attributes: + label: Environment + description: How do you use ZITADEL? + options: + - ZITADEL Cloud + - Self-hosted + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/ISSUE_TEMPLATE/proposal.yaml b/login/.github/ISSUE_TEMPLATE/proposal.yaml new file mode 100644 index 0000000000..cd9ff66972 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/proposal.yaml @@ -0,0 +1,54 @@ +name: 💡 Proposal / Feature request +description: "Create an issue for a feature request/proposal." +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this proposal / feature reqeust + - type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the existing issues, docs, nor discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + - type: textarea + id: problem + attributes: + label: Describe your problem + description: Please describe your problem this proposal / feature is supposed to solve. + placeholder: Describe the problem you have. + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe your ideal solution + description: Which solution do you propose? + placeholder: As a [type of user], I want [some goal] so that [some reason]. + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: Which version of the Typescript Library are you using. + - type: dropdown + id: environment + attributes: + label: Environment + description: How do you use ZITADEL? + options: + - ZITADEL Cloud + - Self-hosted + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/custom-i18n.png b/login/.github/custom-i18n.png new file mode 100644 index 0000000000000000000000000000000000000000..2306e62f8709d5b6f756b773410eca411b3b5c2e GIT binary patch literal 85028 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 z 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/typescript LOGIN_REMOTE_BRANCH=`. + 4. Push your changes and open a pull request to zitadel/zitadel + `.trim(); + await github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body: message + }); + await github.rest.pulls.update({ + ...context.repo, + pull_number: context.issue.number, + state: "closed" + }); diff --git a/login/.github/workflows/issues.yml b/login/.github/workflows/issues.yml new file mode 100644 index 0000000000..ff12b8fe04 --- /dev/null +++ b/login/.github/workflows/issues.yml @@ -0,0 +1,41 @@ +name: Add new issues to product management project + +on: + issues: + types: + - opened + +jobs: + add-to-project: + name: Add issue and community pr to project + runs-on: ubuntu-latest + if: github.repository_id == '622995060' + steps: + - name: add issue + uses: actions/add-to-project@v1.0.2 + if: ${{ github.event_name == 'issues' }} + with: + # You can target a repository in a different organization + # to the issue + project-url: https://github.com/orgs/zitadel/projects/2 + github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} + - uses: tspascoal/get-user-teams-membership@v3 + id: checkUserMember + if: github.actor != 'dependabot[bot]' + with: + username: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }} + - name: add pr + uses: actions/add-to-project@v1.0.2 + if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'engineers')}} + with: + # You can target a repository in a different organization + # to the issue + project-url: https://github.com/orgs/zitadel/projects/2 + github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} + - uses: actions-ecosystem/action-add-labels@v1.1.3 + if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'staff')}} + with: + github_token: ${{ secrets.ADD_TO_PROJECT_PAT }} + labels: | + os-contribution diff --git a/login/.github/workflows/release.yml b/login/.github/workflows/release.yml new file mode 100644 index 0000000000..2508627d1b --- /dev/null +++ b/login/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + branches: + - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + runs-on: ubuntu-latest + if: github.repository_id != '622995060' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install + + - name: Create Release Pull Request + uses: changesets/action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/login/.github/workflows/test.yml b/login/.github/workflows/test.yml new file mode 100644 index 0000000000..7b4721dbee --- /dev/null +++ b/login/.github/workflows/test.yml @@ -0,0 +1,67 @@ +name: Quality +on: + pull_request: + workflow_dispatch: + inputs: + ignore-run-cache: + description: 'Whether to ignore the run cache' + required: false + default: true + ref-tag: + description: 'overwrite the DOCKER_METADATA_OUTPUT_VERSION environment variable used by the make file' + required: false + default: '' +jobs: + quality: + name: Ensure Quality + if: github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.repository_id != '622995060') + runs-on: ubuntu-22.04 + timeout-minutes: 30 + permissions: + contents: read # We only need read access to the repository contents + actions: write # We need write access to the actions cache + env: + CACHE_DIR: /tmp/login-run-caches + # Only run this job on workflow_dispatch or pushes to forks + steps: + - uses: actions/checkout@v4 + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/zitadel/login + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + # Only with correctly restored build cache layers, the run caches work as expected. + # To restore docker build layer caches, extend the docker-bake.hcl to use the cache-from and cache-to options. + # https://docs.docker.com/build/ci/github-actions/cache/ + # Alternatively, you can use a self-hosted runner or a third-party builder that restores build layer caches out-of-the-box, like https://depot.dev/ + - name: Restore Run Caches + uses: actions/cache/restore@v4 + id: run-caches-restore + with: + path: ${{ env.CACHE_DIR }} + key: ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}-${{github.run_attempt}} + restore-keys: | + ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}- + ${{ runner.os }}-login-run-caches-${{github.ref_name}}- + ${{ runner.os }}-login-run-caches- + - run: make login_quality + env: + IGNORE_RUN_CACHE: ${{ github.event.inputs.ignore-run-cache == 'true' }} + DOCKER_METADATA_OUTPUT_VERSION: ${{ github.event.inputs.ref-tag || env.DOCKER_METADATA_OUTPUT_VERSION || steps.meta.outputs.version }} + - name: Save Run Caches + uses: actions/cache/save@v4 + with: + path: ${{ env.CACHE_DIR }} + key: ${{ steps.run-caches-restore.outputs.cache-primary-key }} + if: always() diff --git a/login/.gitignore b/login/.gitignore new file mode 100644 index 0000000000..8d49ae1b37 --- /dev/null +++ b/login/.gitignore @@ -0,0 +1,18 @@ +.DS_Store +node_modules +.turbo +*.log +.next +dist +dist-ssr +*.local +.env +server/dist +public/dist +.vscode +.idea +.vercel +.env*.local +/blob-report/ +/out +/docker diff --git a/login/.npmrc b/login/.npmrc new file mode 100644 index 0000000000..ded82e2f63 --- /dev/null +++ b/login/.npmrc @@ -0,0 +1 @@ +auto-install-peers = true diff --git a/login/.nvmrc b/login/.nvmrc new file mode 100644 index 0000000000..0a47c855eb --- /dev/null +++ b/login/.nvmrc @@ -0,0 +1 @@ +lts/iron \ No newline at end of file diff --git a/login/.prettierignore b/login/.prettierignore new file mode 100644 index 0000000000..77415caa1e --- /dev/null +++ b/login/.prettierignore @@ -0,0 +1,9 @@ +.next/ +.changeset/ +.github/ +dist/ +standalone/ +packages/zitadel-proto/google +packages/zitadel-proto/protoc-gen-openapiv2 +packages/zitadel-proto/validate +packages/zitadel-proto/zitadel diff --git a/login/.prettierrc b/login/.prettierrc new file mode 100644 index 0000000000..ba42405b03 --- /dev/null +++ b/login/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 125, + "trailingComma": "all", + "plugins": ["prettier-plugin-organize-imports"], + "filepath": "" +} diff --git a/login/CODE_OF_CONDUCT.md b/login/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..ac3f129652 --- /dev/null +++ b/login/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +legal@zitadel.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/login/CONTRIBUTING.md b/login/CONTRIBUTING.md new file mode 100644 index 0000000000..783935984f --- /dev/null +++ b/login/CONTRIBUTING.md @@ -0,0 +1,206 @@ +# Contributing + +:attention: In this CONTRIBUTING.md you read about contributing to this very repository. +If you want to develop your own login UI, please refer [to the README.md](./README.md). + +## Introduction + +Thank you for your interest about how to contribute! + +:attention: If you notice a possible **security vulnerability**, please don't hesitate to disclose any concern by contacting [security@zitadel.com](mailto:security@zitadel.com). +You don't have to be perfectly sure about the nature of the vulnerability. +We will give them a high priority and figure them out. + +We also appreciate all your other ideas, thoughts and feedback and will take care of them as soon as possible. +We love to discuss in an open space using [GitHub issues](https://github.com/zitadel/typescript/issues), +[GitHub discussions in the core repo](https://github.com/zitadel/zitadel/discussions) +or in our [chat on Discord](https://zitadel.com/chat). +For private discussions, +you have [more contact options on our Website](https://zitadel.com/contact). + +## Pull Requests + +Please consider the following guidelines when creating a pull request. + +- The latest changes are always in `main`, so please make your pull request against that branch. +- pull requests should be raised for any change +- Pull requests need approval of a Zitadel core engineer @zitadel/engineers before merging +- We use ESLint/Prettier for linting/formatting, so please run `pnpm lint:fix` before committing to make resolving conflicts easier (VSCode users, check out [this ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) and [this Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) to fix lint and formatting issues in development) +- If you add new functionality, please provide the corresponding documentation as well and make it part of the pull request + +### Setting up local environment + +```sh +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Generate gRPC stubs +pnpm generate + +# Start a local development server for the login and manually configure apps/login/.env.local +pnpm dev +``` + +The application is now available at `http://localhost:3000` + +Configure apps/login/.env.local to target the Zitadel instance of your choice. +The login app live-reloads on changes, so you can start developing right away. + +### Developing Against A Local Latest Zitadel Release + +The following command uses Docker to run a local Zitadel instance and the login application in live-reloading dev mode. +Additionally, it runs a Traefik reverse proxy that exposes the login with a self-signed certificate at https://127.0.0.1.sslip.io +127.0.0.1.sslip.io is a special domain that resolves to your localhost, so it's safe to allow your browser to proceed with loading the page. + +```sh +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Generate gRPC stubs +pnpm generate + +# Start a local development server and have apps/login/.env.test.local configured for you to target the local Zitadel instance. +pnpm dev:local +``` + +Log in at https://127.0.0.1.sslip.io/ui/v2/login/loginname and use the following credentials: +**Loginname**: *zitadel-admin@zitadel.127.0.0.1.sslip.io* +**Password**: _Password1!_. + +The login app live-reloads on changes, so you can start developing right away. + +### Developing Against A Locally Compiled Zitadel + +To develop against a locally compiled version of Zitadel, you need to build the Zitadel docker image first. +Clone the [Zitadel repository](https://github.com/zitadel/zitadel.git) and run the following command from its root: + +```sh +# This compiles a Zitadel binary if it does not exist at ./zitadel already and copies it into a Docker image. +# If you want to recompile the binary, run `make compile` first +make login_dev +``` + +Open another terminal session at zitadel/zitadel/login and run the following commands to start the dev server. + +```bash +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Start a local development server and have apps/login/.env.test.local configured for you to target the local Zitadel instance. +NODE_ENV=test pnpm dev +``` + +Log in at https://127.0.0.1.sslip.io/ui/v2/login/loginname and use the following credentials: +**Loginname**: *zitadel-admin@zitadel.127.0.0.1.sslip.io* +**Password**: _Password1!_. + +The login app live-reloads on changes, so you can start developing right away. + +### Quality Assurance + +Use `make` commands to test the quality of your code against a production build without installing any dependencies besides Docker. +Using `make` commands, you can reproduce and debug the CI pipelines locally. + +```sh +# Reproduce the whole CI pipeline in docker +make login_quality +# Show other options with make +make help +``` + +Use `pnpm` commands to run the tests in dev mode with live reloading and debugging capabilities. + +#### Linting and formatting + +Check the formatting and linting of the code in docker + +```sh +make login_lint +``` + +Check the linting of the code using pnpm + +```sh +pnpm lint +pnpm format +``` + +Fix the linting of your code + +```sh +pnpm lint:fix +pnpm format:fix +``` + +#### Running Unit Tests + +Run the tests in docker + +```sh +make login_test_unit +``` + +Run unit tests with live-reloading + +```sh +pnpm test:unit +``` + +#### Running Integration Tests + +Run the test in docker + +```sh +make login_test_integration +``` + +Alternatively, run a live-reloading development server with an interactive Cypress test suite. +First, set up your local test environment. + +```sh +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Generate gRPC stubs +pnpm generate + +# Start a local development server and use apps/login/.env.test to use the locally mocked Zitadel API. +pnpm test:integration:setup +``` + +Now, in another terminal session, open the interactive Cypress integration test suite. + +```sh +pnpm test:integration open +``` + +Show more options with Cypress + +```sh +pnpm test:integration help +``` + +#### Running Acceptance Tests + +To run the tests in docker against the latest release of Zitadel, use the following command: + +:warning: The acceptance tests are not reliable at the moment :construction: + +```sh +make login_test_acceptance +``` + +Alternatively, run can use a live-reloading development server with an interactive Playwright test suite. +Set up your local environment by running the commands either for [developing against a local latest Zitadel release](latest) or for [developing against a locally compiled Zitadel](compiled). + +Now, in another terminal session, open the interactive Playwright acceptance test suite. + +```sh +pnpm test:acceptance open +``` + +Show more options with Playwright + +```sh +pnpm test:acceptance help +``` diff --git a/login/LICENSE b/login/LICENSE new file mode 100644 index 0000000000..89f750f2ab --- /dev/null +++ b/login/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 ZITADEL + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/login/Makefile b/login/Makefile new file mode 100644 index 0000000000..a6e781374b --- /dev/null +++ b/login/Makefile @@ -0,0 +1,137 @@ +XDG_CACHE_HOME ?= $(HOME)/.cache +export CACHE_DIR ?= $(XDG_CACHE_HOME)/zitadel-make + +LOGIN_DIR ?= ./ +LOGIN_BAKE_CLI ?= docker buildx bake +LOGIN_BAKE_CLI_WITH_ARGS := $(LOGIN_BAKE_CLI) --file $(LOGIN_DIR)docker-bake.hcl --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml +LOGIN_BAKE_CLI_ADDITIONAL_ARGS ?= +LOGIN_BAKE_CLI_WITH_ARGS += $(LOGIN_BAKE_CLI_ADDITIONAL_ARGS) + +export COMPOSE_BAKE=true +export UID := $(id -u) +export GID := $(id -g) + +export LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT := $(LOGIN_DIR)apps/login-test-acceptance + +export DOCKER_METADATA_OUTPUT_VERSION ?= local +export LOGIN_TAG ?= login:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_UNIT_TAG := login-test-unit:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_INTEGRATION_TAG := login-test-integration:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_TAG := login-test-acceptance:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SETUP_TAG := login-test-acceptance-setup:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SINK_TAG := login-test-acceptance-sink:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_OIDCRP_TAG := login-test-acceptance-oidcrp:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_OIDCOP_TAG := login-test-acceptance-oidcop:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SAMLSP_TAG := login-test-acceptance-samlsp:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SAMLIDP_TAG := login-test-acceptance-samlidp:${DOCKER_METADATA_OUTPUT_VERSION} +export POSTGRES_TAG := postgres:17.0-alpine3.19 +export GOLANG_TAG := golang:1.24-alpine +export ZITADEL_TAG ?= ghcr.io/zitadel/zitadel:latest +export LOGIN_CORE_MOCK_TAG := login-core-mock:${DOCKER_METADATA_OUTPUT_VERSION} + +login_help: + @echo "Makefile for the login service" + @echo "Available targets:" + @echo " login_help - Show this help message." + @echo " login_quality - Run all quality checks (login_lint, login_test_unit, login_test_integration, login_test_acceptance)." + @echo " login_standalone_build - Build the docker image for production login containers." + @echo " login_lint - Run linting and formatting checks. IGNORE_RUN_CACHE=true prevents skipping." + @echo " login_test_unit - Run unit tests. Tests without any dependencies. IGNORE_RUN_CACHE=true prevents skipping." + @echo " login-test_integration - Run integration tests. Tests a login production build against a mocked Zitadel core API. IGNORE_RUN_CACHE=true prevents skipping." + @echo " login_test_acceptance - Run acceptance tests. Tests a login production build with a local Zitadel instance behind a reverse proxy. IGNORE_RUN_CACHE=true prevents skipping." + @echo " typescript_generate - Generate TypeScript client code from Protobuf definitions." + @echo " show_run_caches - Show all run caches with image ids and exit codes." + @echo " clean_run_caches - Remove all run caches." + + +login_lint: + @echo "Running login linting and formatting checks" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-lint + +login_test_unit: + @echo "Running login unit tests" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-test-unit + +login_test_integration_build: + @echo "Building login integration test environment with the local core mock image" + $(LOGIN_BAKE_CLI_WITH_ARGS) core-mock login-test-integration login-standalone --load + +login_test_integration_dev: login_test_integration_cleanup + @echo "Starting login integration test environment with the local core mock image" + $(LOGIN_BAKE_CLI_WITH_ARGS) core-mock && docker compose --file $(LOGIN_DIR)apps/login-test-integration/docker-compose.yaml run --service-ports --rm core-mock + +login_test_integration_run: login_test_integration_cleanup + @echo "Running login integration tests" + docker compose --file $(LOGIN_DIR)apps/login-test-integration/docker-compose.yaml run --rm integration + +login_test_integration_cleanup: + @echo "Cleaning up login integration test environment" + docker compose --file $(LOGIN_DIR)apps/login-test-integration/docker-compose.yaml down --volumes + +login_test_integration: login_test_integration_build + $(LOGIN_DIR)scripts/run_or_skip.sh login_test_integration_run \ + "$(LOGIN_TAG) \ + $(LOGIN_CORE_MOCK_TAG) \ + $(LOGIN_TEST_INTEGRATION_TAG)" + +login_test_acceptance_build_bake: + @echo "Building login test acceptance images as defined in the docker-bake.hcl" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-test-acceptance login-standalone --load + +login_test_acceptance_build_compose: + @echo "Building login test acceptance images as defined in the docker-compose.yaml" + $(LOGIN_BAKE_CLI_WITH_ARGS) --load setup sink + +# login_test_acceptance_build is overwritten by the login_dev target in zitadel/zitadel/Makefile +login_test_acceptance_build: login_test_acceptance_build_compose login_test_acceptance_build_bake + +login_test_acceptance_run: login_test_acceptance_cleanup + @echo "Running login test acceptance tests" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose-ci.yaml run --rm --service-ports acceptance + +login_test_acceptance_cleanup: + @echo "Cleaning up login test acceptance environment" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose-ci.yaml down --volumes + +login_test_acceptance: login_test_acceptance_build + $(LOGIN_DIR)scripts/run_or_skip.sh login_test_acceptance_run \ + "$(LOGIN_TAG) \ + $(ZITADEL_TAG) \ + $(POSTGRES_TAG) \ + $(GOLANG_TAG) \ + $(LOGIN_TEST_ACCEPTANCE_TAG) \ + $(LOGIN_TEST_ACCEPTANCE_SETUP_TAG) \ + $(LOGIN_TEST_ACCEPTANCE_SINK_TAG)" + +login_test_acceptance_setup_env: login_test_acceptance_build_compose login_test_acceptance_cleanup + @echo "Setting up the login test acceptance environment and writing the env.test.local file" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml run setup + +login_test_acceptance_setup_dev: + @echo "Starting the login test acceptance environment with the local zitadel image" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml up --no-recreate zitadel traefik sink + +login_quality: login_lint login_test_unit login_test_integration + @echo "Running login quality checks: lint, unit tests, integration tests" + +login_standalone_build: + @echo "Building the login standalone docker image with tag: $(LOGIN_TAG)" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-standalone --load + +login_standalone_out: + $(LOGIN_BAKE_CLI_WITH_ARGS) login-standalone-out + +typescript_generate: + @echo "Generating TypeScript client and writing to local $(LOGIN_DIR)packages/zitadel-proto" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-typescript-proto-client-out + +clean_run_caches: + @echo "Removing cache directory: $(CACHE_DIR)" + rm -rf "$(CACHE_DIR)" + +show_run_caches: + @echo "Showing run caches with docker image ids and exit codes in $(CACHE_DIR):" + @find "$(CACHE_DIR)" -type f 2>/dev/null | while read file; do \ + echo "$$file: $$(cat $$file)"; \ + done + diff --git a/login/README.md b/login/README.md new file mode 100644 index 0000000000..c3601e666b --- /dev/null +++ b/login/README.md @@ -0,0 +1,264 @@ +# ZITADEL TypeScript with Turborepo + +This repository contains all TypeScript and JavaScript packages and applications you need to create your own ZITADEL +Login UI. + +collage of login screens + +[![npm package](https://img.shields.io/npm/v/@zitadel/proto.svg?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/@zitadel/proto) +[![npm package](https://img.shields.io/npm/v/@zitadel/client.svg?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/@zitadel/client) + +**⚠️ This repo and packages are in beta state and subject to change ⚠️** + +The scope of functionality of this repo and packages is under active development. + +The `@zitadel/client` package is using [@connectrpc/connect](https://github.com/connectrpc/connect-es#readme). + +You can read the [contribution guide](/CONTRIBUTING.md) on how to contribute. +Questions can be raised in our [Discord channel](https://discord.gg/erh5Brh7jE) or as +a [GitHub issue](https://github.com/zitadel/typescript/issues). + +## Developing Your Own ZITADEL Login UI + +We think the easiest path of getting up and running, is the following: + +1. Fork and clone this repository +1. [Run the ZITADEL Cloud login UI locally](#run-login-ui) +1. Make changes to the code and see the effects live on your local machine +1. Study the rest of this README.md and get familiar and comfortable with how everything works. +1. Decide on a way of how you want to build and run your login UI. + You can reuse ZITADEL Clouds way. + But if you need more freedom, you can also import the packages you need into your self built application. + +## Included Apps And Packages + +- `login`: The login UI used by ZITADEL Cloud, powered by Next.js +- `@zitadel/client`: shared client utilities for node and browser environments +- `@zitadel/proto`: Protocol Buffers (proto) definitions used by ZITADEL projects +- `@zitadel/tsconfig`: shared `tsconfig.json`s used throughout the monorepo +- `@zitadel/eslint-config`: ESLint preset + +Each package and app is 100% [TypeScript](https://www.typescriptlang.org/). + +### Login + +The login is currently in a work in progress state. +The goal is to implement a login UI, using the session API of ZITADEL, which also implements the OIDC Standard and is +ready to use for everyone. + +In the first phase we want to have a MVP login ready with the OIDC Standard and a basic feature set. In a second step +the features will be extended. + +This list should show the current implementation state, and also what is missing. +You can already use the current state, and extend it with your needs. + +#### Features list + +- [x] Local User Registration (with Password) +- [x] User Registration and Login with external Provider + - [x] Google + - [x] GitHub + - [x] GitHub Enterprise + - [x] GitLab + - [x] GitLab Enterprise + - [x] Azure + - [x] Apple + - [x] Generic OIDC + - [x] Generic OAuth + - [x] Generic JWT + - [x] LDAP + - [x] SAML SP +- Multifactor Registration an Login + - [x] Passkeys + - [x] TOTP + - [x] OTP: Email Code + - [x] OTP: SMS Code +- [x] Password Change/Reset +- [x] Domain Discovery +- [x] Branding +- OIDC Standard + + - [x] Authorization Code Flow with PKCE + - [x] AuthRequest `hintUserId` + - [x] AuthRequest `loginHint` + - [x] AuthRequest `prompt` + - [x] Login + - [x] Select Account + - [ ] Consent + - [x] Create + - Scopes + - [x] `openid email profile address`` + - [x] `offline access` + - [x] `urn:zitadel:iam:org:idp:id:{idp_id}` + - [x] `urn:zitadel:iam:org:project:id:zitadel:aud` + - [x] `urn:zitadel:iam:org:id:{orgid}` + - [x] `urn:zitadel:iam:org:domain:primary:{domain}` + - [ ] AuthRequest UI locales + + #### Flow diagram + + This diagram shows the available pages and flows. + + > Note that back navigation or retries are not displayed. + +```mermaid + flowchart TD + A[Start] --> register + A[Start] --> accounts + A[Start] --> loginname + loginname -- signInWithIDP --> idp-success + loginname -- signInWithIDP --> idp-failure + idp-success --> B[signedin] + loginname --> password + loginname -- hasPasskey --> passkey + loginname -- allowRegister --> register + passkey-add --passwordAllowed --> password + passkey -- hasPassword --> password + passkey --> B[signedin] + password -- hasMFA --> mfa + password -- allowPasskeys --> passkey-add + password -- reset --> password-set + email -- reset --> password-set + password-set --> B[signedin] + password-change --> B[signedin] + password -- userstate=initial --> password-change + + mfa --> otp + otp --> B[signedin] + mfa--> u2f + u2f -->B[signedin] + register -- password/passkey --> B[signedin] + password --> B[signedin] + password-- forceMFA -->mfaset + mfaset --> u2fset + mfaset --> otpset + u2fset --> B[signedin] + otpset --> B[signedin] + accounts--> loginname + password -- not verified yet -->verify + register-- withpassword -->verify + passkey-- notVerified --> verify + verify --> B[signedin] +``` + +You can find a more detailed documentation of the different pages [here](./apps/login/readme.md). + +#### Custom translations + +The new login uses the [SettingsApi](https://zitadel.com/docs/apis/resources/settings_service_v2/settings-service-get-hosted-login-translation) to load custom translations. +Translations can be overriden at both the instance and organization levels. +To find the keys more easily, you can inspect the HTML and search for a `data-i18n-key` attribute, or look at the defaults in `/apps/login/locales/[locale].ts`. +![Custom Translations](.github/custom-i18n.png) + +## Tooling + +- [TypeScript](https://www.typescriptlang.org/) for static type checking +- [ESLint](https://eslint.org/) for code linting +- [Prettier](https://prettier.io) for code formatting + +## Useful Commands + +- `make login-quality` - Check the quality of your code against a production build without installing any dependencies besides Docker +- `pnpm generate` - Build proto stubs for the client package +- `pnpm dev` - Develop all packages and the login app +- `pnpm build` - Build all packages and the login app +- `pnpm clean` - Clean up all `node_modules` and `dist` folders (runs each package's clean script) + +Learn more about developing the login UI in the [contribution guide](/CONTRIBUTING.md). + +## Versioning And Publishing Packages + +Package publishing has been configured using [Changesets](https://github.com/changesets/changesets). +Here is their [documentation](https://github.com/changesets/changesets#documentation) for more information about the +workflow. + +The [GitHub Action](https://github.com/changesets/action) needs an `NPM_TOKEN` and `GITHUB_TOKEN` in the repository +settings. The [Changesets bot](https://github.com/apps/changeset-bot) should also be installed on the GitHub repository. + +Read the [changesets documentation](https://github.com/changesets/changesets/blob/main/docs/automating-changesets.md) +for more information about this automation + +### Run Login UI + +To run the application make sure to install the dependencies with + +```sh +pnpm install +``` + +then generate the GRPC stubs with + +```sh +pnpm generate +``` + +To run the application against a local ZITADEL instance, run the following command: + +```sh +pnpm run-zitadel +``` + +This sets up ZITADEL using docker compose and writes the configuration to the file `apps/login/.env.local`. + +

+Alternatively, use another environment +You can develop against any ZITADEL instance in which you have sufficient rights to execute the following steps. +Just create or overwrite the file `apps/login/.env.local` yourself. +Add your instances base URL to the file at the key `ZITADEL_API_URL`. +Go to your instance and create a service user for the login application. +The login application creates users on your primary organization and reads policy data. +For the sake of simplicity, just make the service user an instance member with the role `IAM_OWNER`. +Create a PAT and copy it to the file `apps/login/.env.local` using the key `ZITADEL_SERVICE_USER_TOKEN`. + +The file should look similar to this: + +``` +ZITADEL_API_URL=https://zitadel-tlx3du.us1.zitadel.cloud +ZITADEL_SERVICE_USER_TOKEN=1S6w48thfWFI2klgfwkCnhXJLf9FQ457E-_3H74ePQxfO3Af0Tm4V5Xi-ji7urIl_xbn-Rk +``` + +
+ +Start the login application in dev mode: + +```sh +pnpm dev +``` + +Open the login application with your favorite browser at `localhost:3000`. +Change the source code and see the changes live in your browser. + +Make sure the application still behaves as expected by running all tests + +```sh +pnpm test +``` + +To satisfy your unique workflow requirements, check out the package.json in the root directory for more detailed scripts. + +### Run Login UI Acceptance tests + +To run the acceptance tests you need a running ZITADEL environment and a component which receives HTTP requests for the emails and sms's. +This component should also be able to return the content of these notifications, as the codes and links are used in the login flows. +There is a basic implementation in Golang available under [the sink package](./acceptance/sink). + +To setup ZITADEL with the additional Sink container for handling the notifications: + +```sh +pnpm run-sink +``` + +Then you can start the acceptance tests with: + +```sh +pnpm test:acceptance +``` + +### Deploy to Vercel + +To deploy your own version on Vercel, navigate to your instance and create a service user. +Then create a personal access token (PAT), copy and set it as ZITADEL_SERVICE_USER_TOKEN, then navigate to your instance +settings and make sure it gets IAM_OWNER permissions. +Finally set your instance url as ZITADEL_API_URL. Make sure to set it without trailing slash. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzitadel%2Ftypescript&env=ZITADEL_API_URL,ZITADEL_SERVICE_USER_TOKEN&root-directory=apps/login&envDescription=Setup%20a%20service%20account%20with%20IAM_LOGIN_CLIENT%20membership%20on%20your%20instance%20and%20provide%20its%20personal%20access%20token.&project-name=zitadel-login&repository-name=zitadel-login) diff --git a/login/acceptance/docker-compose.yaml b/login/acceptance/docker-compose.yaml new file mode 100644 index 0000000000..a68a435e83 --- /dev/null +++ b/login/acceptance/docker-compose.yaml @@ -0,0 +1,71 @@ +services: + zitadel: + user: "${ZITADEL_DEV_UID}" + image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:02617cf17fdde849378c1a6b5254bbfb2745b164}" + command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml' + ports: + - "8080:8080" + volumes: + - ./pat:/pat + - ./zitadel.yaml:/zitadel.yaml + depends_on: + db: + condition: "service_healthy" + extra_hosts: + - "localhost:host-gateway" + + db: + restart: "always" + image: postgres:17.0-alpine3.19 + environment: + - POSTGRES_USER=zitadel + - PGUSER=zitadel + - POSTGRES_DB=zitadel + - POSTGRES_HOST_AUTH_METHOD=trust + command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0 + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: "10s" + timeout: "30s" + retries: 5 + start_period: "20s" + ports: + - 5432:5432 + + wait_for_zitadel: + image: curlimages/curl:8.00.1 + command: /bin/sh -c "until curl -s -o /dev/null -i -f http://zitadel:8080/debug/ready; do echo 'waiting' && sleep 1; done; echo 'ready' && sleep 5;" || false + depends_on: + - zitadel + + setup: + user: "${ZITADEL_DEV_UID}" + container_name: setup + image: acceptance-setup:latest + environment: + PAT_FILE: /pat/zitadel-admin-sa.pat + ZITADEL_API_INTERNAL_URL: http://zitadel:8080 + WRITE_ENVIRONMENT_FILE: /apps/login/.env.local + WRITE_TEST_ENVIRONMENT_FILE: /acceptance/tests/.env.local + SINK_EMAIL_INTERNAL_URL: http://sink:3333/email + SINK_SMS_INTERNAL_URL: http://sink:3333/sms + SINK_NOTIFICATION_URL: http://localhost:3333/notification + volumes: + - "./pat:/pat" + - "../apps/login:/apps/login" + - "../acceptance/tests:/acceptance/tests" + depends_on: + wait_for_zitadel: + condition: "service_completed_successfully" + + sink: + image: golang:1.24-alpine + container_name: sink + command: go run /sink/main.go -port '3333' -email '/email' -sms '/sms' -notification '/notification' + ports: + - 3333:3333 + volumes: + - "./sink:/sink" + depends_on: + setup: + condition: "service_completed_successfully" diff --git a/login/apps/login-test-acceptance/.gitignore b/login/apps/login-test-acceptance/.gitignore new file mode 100644 index 0000000000..6a7425e885 --- /dev/null +++ b/login/apps/login-test-acceptance/.gitignore @@ -0,0 +1 @@ +go-command diff --git a/login/apps/login-test-acceptance/docker-compose-ci.yaml b/login/apps/login-test-acceptance/docker-compose-ci.yaml new file mode 100644 index 0000000000..7a531fcf42 --- /dev/null +++ b/login/apps/login-test-acceptance/docker-compose-ci.yaml @@ -0,0 +1,59 @@ +services: + + zitadel: + environment: + ZITADEL_EXTERNALDOMAIN: traefik + + traefik: + labels: !reset [] + + setup: + environment: + ZITADEL_API_DOMAIN: traefik + ZITADEL_API_URL: https://traefik + LOGIN_BASE_URL: https://traefik/ui/v2/login/ + SINK_NOTIFICATION_URL: http://sink:3333/notification + ZITADEL_ADMIN_USER: zitadel-admin@zitadel.traefik + + login: + image: "${LOGIN_TAG:-login:local}" + container_name: acceptance-login + labels: + - "traefik.enable=true" + - "traefik.http.routers.login.rule=PathPrefix(`/ui/v2/login`)" + ports: + - "3000:3000" + environment: + - NODE_TLS_REJECT_UNAUTHORIZED=0 + depends_on: + setup: + condition: service_completed_successfully + + acceptance: + image: "${LOGIN_TEST_ACCEPTANCE_TAG:-login-test-acceptance:local}" + container_name: acceptance + environment: + - CI + - LOGIN_BASE_URL=https://traefik/ui/v2/login/ + - NODE_TLS_REJECT_UNAUTHORIZED=0 + volumes: + - ../login/.env.test.local:/build/apps/login/.env.test.local + - ./test-results:/build/apps/login-test-acceptance/test-results + - ./playwright-report:/build/apps/login-test-acceptance/playwright-report + ports: + - 9323:9323 + ipc: "host" + init: true + depends_on: + login: + condition: "service_healthy" + sink: + condition: service_healthy +# oidcrp: +# condition: service_healthy +# oidcop: +# condition: service_healthy +# samlsp: +# condition: service_healthy +# samlidp: +# condition: service_healthy diff --git a/login/apps/login-test-acceptance/docker-compose.yaml b/login/apps/login-test-acceptance/docker-compose.yaml new file mode 100644 index 0000000000..cb0463fdc8 --- /dev/null +++ b/login/apps/login-test-acceptance/docker-compose.yaml @@ -0,0 +1,237 @@ +services: + + zitadel: + user: "${UID:-1000}:${GID:-1000}" + image: "${ZITADEL_TAG:-ghcr.io/zitadel/zitadel:latest}" + container_name: acceptance-zitadel + command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --config /zitadel.yaml --steps /zitadel.yaml' + labels: + - "traefik.enable=true" + - "traefik.http.routers.zitadel.rule=!PathPrefix(`/ui/v2/login`)" + # - "traefik.http.middlewares.zitadel.headers.customrequestheaders.Host=localhost" +# - "traefik.http.routers.zitadel.middlewares=zitadel@docker" + - "traefik.http.services.zitadel-service.loadbalancer.server.scheme=h2c" + ports: + - "8080:8080" + volumes: + - ./pat:/pat + - ./zitadel.yaml:/zitadel.yaml + depends_on: + db: + condition: "service_healthy" + + db: + restart: "always" + image: ${LOGIN_TEST_ACCEPTANCE_POSTGES_TAG:-postgres:17.0-alpine3.19} + container_name: acceptance-db + environment: + - POSTGRES_USER=zitadel + - PGUSER=zitadel + - POSTGRES_DB=zitadel + - POSTGRES_HOST_AUTH_METHOD=trust + command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0 + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: "10s" + timeout: "30s" + retries: 5 + start_period: "20s" + ports: + - "5432:5432" + + wait-for-zitadel: + image: curlimages/curl:8.00.1 + container_name: acceptance-wait-for-zitadel + command: /bin/sh -c "until curl -s -o /dev/null -i -f http://zitadel:8080/debug/ready; do echo 'waiting' && sleep 1; done; echo 'ready' && sleep 5;" || false + depends_on: + - zitadel + + traefik: + image: "traefik:v3.4" + container_name: "acceptance-traefik" + labels: + - "traefik.enable=true" + - "traefik.http.routers.login.rule=PathPrefix(`/ui/v2/login`)" + - "traefik.http.services.login-service.loadbalancer.server.url=http://host.docker.internal:3000" + command: +# - "--log.level=DEBUG" + - "--ping" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.websecure.http.tls=true" + - "--entryPoints.websecure.address=:443" + healthcheck: + test: ["CMD", "traefik", "healthcheck", "--ping"] + interval: "10s" + timeout: "30s" + retries: 5 + start_period: "20s" + ports: + - "443:443" + - "8090:8080" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + extra_hosts: + - host.docker.internal:host-gateway + + setup: + user: "${UID:-1000}:${GID:-1000}" + image: ${LOGIN_TEST_ACCEPTANCE_SETUP_TAG:-login-test-acceptance-setup:local} + container_name: acceptance-setup + restart: no + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/setup" + dockerfile: ../go-command.Dockerfile + entrypoint: "./setup.sh" + environment: + PAT_FILE: /pat/zitadel-admin-sa.pat + ZITADEL_API_INTERNAL_URL: http://zitadel:8080 + WRITE_ENVIRONMENT_FILE: /login-env/.env.test.local + SINK_EMAIL_INTERNAL_URL: http://sink:3333/email + SINK_SMS_INTERNAL_URL: http://sink:3333/sms + SINK_NOTIFICATION_URL: http://localhost:3333/notification + LOGIN_BASE_URL: https://127.0.0.1.sslip.io/ui/v2/login/ + ZITADEL_API_URL: https://127.0.0.1.sslip.io + ZITADEL_API_DOMAIN: 127.0.0.1.sslip.io + ZITADEL_ADMIN_USER: zitadel-admin@zitadel.127.0.0.1.sslip.io + volumes: + - ./pat:/pat # Read the PAT file from zitadels setup + - ../login:/login-env # Write the environment variables file for the login + depends_on: + traefik: + condition: "service_healthy" + wait-for-zitadel: + condition: "service_completed_successfully" + + sink: + image: ${LOGIN_TEST_ACCEPTANCE_SINK_TAG:-login-test-acceptance-sink:local} + container_name: acceptance-sink + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/sink" + dockerfile: ../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + PORT: '3333' + command: + - -port + - '3333' + - -email + - '/email' + - -sms + - '/sms' + - -notification + - '/notification' + ports: + - "3333:3333" + depends_on: + setup: + condition: "service_completed_successfully" + + oidcrp: + user: "${UID:-1000}:${GID:-1000}" + image: ${LOGIN_TEST_ACCEPTANCE_OIDCRP_TAG:-login-test-acceptance-oidcrp:local} + container_name: acceptance-oidcrp + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/oidcrp" + dockerfile: ../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + LOGIN_URL: 'https://traefik/ui/v2/login' + ISSUER: 'https://traefik' + HOST: 'traefik' + PORT: '8000' + SCOPES: 'openid profile email' + ports: + - "8000:8000" + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" + + oidcop: + user: "${UID:-1000}:${GID:-1000}" + image: ${LOGIN_TEST_ACCEPTANCE_OIDCOP_TAG:-login-test-acceptance-oidcop:local} + container_name: acceptance-oidcop + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/idp/oidc" + dockerfile: ../../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + SCHEMA: 'https' + HOST: 'traefik' + PORT: "8004" + ports: + - 8004:8004 + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" + + samlsp: + user: "${UID:-1000}:${GID:-1000}" + image: "${LOGIN_TEST_ACCEPTANCE_SAMLSP_TAG:-login-test-acceptance-samlsp:local}" + container_name: acceptance-samlsp + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/samlsp" + dockerfile: ../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + LOGIN_URL: 'https://traefik/ui/v2/login' + IDP_URL: 'http://zitadel:8080/saml/v2/metadata' + HOST: 'https://traefik' + PORT: '8001' + ports: + - 8001:8001 + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" + + samlidp: + user: "${UID:-1000}:${GID:-1000}" + image: "${LOGIN_TEST_ACCEPTANCE_SAMLIDP_TAG:-login-test-acceptance-samlidp:local}" + container_name: acceptance-samlidp + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/idp/saml" + dockerfile: ../../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik:8080' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + SCHEMA: 'https' + HOST: 'traefik' + PORT: "8003" + ports: + - 8003:8003 + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" diff --git a/login/apps/login-test-acceptance/go-command.Dockerfile b/login/apps/login-test-acceptance/go-command.Dockerfile new file mode 100644 index 0000000000..fafebd6f4d --- /dev/null +++ b/login/apps/login-test-acceptance/go-command.Dockerfile @@ -0,0 +1,11 @@ +ARG LOGIN_TEST_ACCEPTANCE_GOLANG_TAG="golang:1.24-alpine" + +FROM ${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG} +RUN apk add curl jq +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -o /go-command . +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s \ + CMD curl -f http://localhost:${PORT}/healthy || exit 1 +ENTRYPOINT [ "/go-command" ] diff --git a/login/apps/login-test-acceptance/idp/oidc/go.mod b/login/apps/login-test-acceptance/idp/oidc/go.mod new file mode 100644 index 0000000000..84dae766c8 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/oidc/go.mod @@ -0,0 +1,28 @@ +module github.com/zitadel/typescript/acceptance/idp/oidc + +go 1.24.1 + +require github.com/zitadel/oidc/v3 v3.37.0 + +require ( + github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect + github.com/go-chi/chi/v5 v5.2.1 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/muhlemmer/httpforwarded v0.1.0 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/zitadel/logging v0.6.2 // indirect + github.com/zitadel/schema v1.3.1 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/login/apps/login-test-acceptance/idp/oidc/go.sum b/login/apps/login-test-acceptance/idp/oidc/go.sum new file mode 100644 index 0000000000..42d80d8683 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/oidc/go.sum @@ -0,0 +1,71 @@ +github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= +github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU= +github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4= +github.com/zitadel/oidc/v3 v3.37.0 h1:nYATWlnP7f18XiAbw6upUruBaqfB1kUrXrSTf1EYGO8= +github.com/zitadel/oidc/v3 v3.37.0/go.mod h1:/xDan4OUQhguJ4Ur73OOJrtugvR164OMnidXP9xfVNw= +github.com/zitadel/schema v1.3.1 h1:QT3kwiRIRXXLVAs6gCK/u044WmUVh6IlbLXUsn6yRQU= +github.com/zitadel/schema v1.3.1/go.mod h1:071u7D2LQacy1HAN+YnMd/mx1qVE2isb0Mjeqg46xnU= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/login/apps/login-test-acceptance/idp/oidc/main.go b/login/apps/login-test-acceptance/idp/oidc/main.go new file mode 100644 index 0000000000..b04ac94234 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/oidc/main.go @@ -0,0 +1,186 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/zitadel/oidc/v3/example/server/exampleop" + "github.com/zitadel/oidc/v3/example/server/storage" +) + +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + schema := os.Getenv("SCHEMA") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + + logger := slog.New( + slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelDebug, + }), + ) + + issuer := fmt.Sprintf("%s://%s:%s/", schema, host, port) + redirectURI := fmt.Sprintf("%s/idps/callback", apiURL) + + clientID := "web" + clientSecret := "secret" + storage.RegisterClients( + storage.WebClient(clientID, clientSecret, redirectURI), + ) + + storage := storage.NewStorage(storage.NewUserStore(issuer)) + router := exampleop.SetupServer(issuer, storage, logger, false) + + server := &http.Server{ + Addr: ":" + port, + Handler: router, + } + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + log.Println("Stopped serving new connections.") + }() + + createZitadelResources(apiURL, pat, domain, issuer, clientID, clientSecret) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownRelease() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func createZitadelResources(apiURL, pat, domain, issuer, clientID, clientSecret string) error { + idpID, err := CreateIDP(apiURL, pat, domain, issuer, clientID, clientSecret) + if err != nil { + return err + } + return ActivateIDP(apiURL, pat, domain, idpID) +} + +type createIDP struct { + Name string `json:"name"` + Issuer string `json:"issuer"` + ClientId string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + Scopes []string `json:"scopes"` + ProviderOptions providerOptions `json:"providerOptions"` + IsIdTokenMapping bool `json:"isIdTokenMapping"` + UsePkce bool `json:"usePkce"` +} + +type providerOptions struct { + IsLinkingAllowed bool `json:"isLinkingAllowed"` + IsCreationAllowed bool `json:"isCreationAllowed"` + IsAutoCreation bool `json:"isAutoCreation"` + IsAutoUpdate bool `json:"isAutoUpdate"` + AutoLinking string `json:"autoLinking"` +} + +type idp struct { + ID string `json:"id"` +} + +func CreateIDP(apiURL, pat, domain string, issuer, clientID, clientSecret string) (string, error) { + createIDP := &createIDP{ + Name: "OIDC", + Issuer: issuer, + ClientId: clientID, + ClientSecret: clientSecret, + Scopes: []string{"openid", "profile", "email"}, + ProviderOptions: providerOptions{ + IsLinkingAllowed: true, + IsCreationAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: "AUTO_LINKING_OPTION_USERNAME", + }, + IsIdTokenMapping: false, + UsePkce: false, + } + + resp, err := doRequestWithHeaders(apiURL+"/admin/v1/idps/generic_oidc", pat, domain, createIDP) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + idp := new(idp) + if err := json.Unmarshal(data, idp); err != nil { + return "", err + } + return idp.ID, nil +} + +type activateIDP struct { + IdpId string `json:"idpId"` +} + +func ActivateIDP(apiURL, pat, domain string, idpID string) error { + activateIDP := &activateIDP{ + IdpId: idpID, + } + _, err := doRequestWithHeaders(apiURL+"/admin/v1/policies/login/idps", pat, domain, activateIDP) + return err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/idp/saml/go.mod b/login/apps/login-test-acceptance/idp/saml/go.mod new file mode 100644 index 0000000000..e73b4feb3b --- /dev/null +++ b/login/apps/login-test-acceptance/idp/saml/go.mod @@ -0,0 +1,16 @@ +module github.com/zitadel/typescript/acceptance/idp/saml + +go 1.24.1 + +require ( + github.com/crewjam/saml v0.4.14 + github.com/mattermost/xml-roundtrip-validator v0.1.0 + github.com/zenazn/goji v1.0.1 + golang.org/x/crypto v0.36.0 +) + +require ( + github.com/beevik/etree v1.1.0 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/russellhaering/goxmldsig v1.3.0 // indirect +) diff --git a/login/apps/login-test-acceptance/idp/saml/go.sum b/login/apps/login-test-acceptance/idp/saml/go.sum new file mode 100644 index 0000000000..1208550f6e --- /dev/null +++ b/login/apps/login-test-acceptance/idp/saml/go.sum @@ -0,0 +1,49 @@ +github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= +github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= +github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/russellhaering/goxmldsig v1.3.0 h1:DllIWUgMy0cRUMfGiASiYEa35nsieyD3cigIwLonTPM= +github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/zenazn/goji v1.0.1 h1:4lbD8Mx2h7IvloP7r2C0D6ltZP6Ufip8Hn0wmSK5LR8= +github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/login/apps/login-test-acceptance/idp/saml/main.go b/login/apps/login-test-acceptance/idp/saml/main.go new file mode 100644 index 0000000000..059eab79e2 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/saml/main.go @@ -0,0 +1,328 @@ +package main + +import ( + "bytes" + "crypto" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "encoding/xml" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/crewjam/saml" + "github.com/crewjam/saml/logger" + "github.com/crewjam/saml/samlidp" + xrv "github.com/mattermost/xml-roundtrip-validator" + "github.com/zenazn/goji" + "github.com/zenazn/goji/bind" + "github.com/zenazn/goji/web" + "golang.org/x/crypto/bcrypt" +) + +var key = func() crypto.PrivateKey { + b, _ := pem.Decode([]byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0OhbMuizgtbFOfwbK7aURuXhZx6VRuAs3nNibiuifwCGz6u9 +yy7bOR0P+zqN0YkjxaokqFgra7rXKCdeABmoLqCC0U+cGmLNwPOOA0PaD5q5xKhQ +4Me3rt/R9C4Ca6k3/OnkxnKwnogcsmdgs2l8liT3qVHP04Oc7Uymq2v09bGb6nPu +fOrkXS9F6mSClxHG/q59AGOWsXK1xzIRV1eu8W2SNdyeFVU1JHiQe444xLoPul5t +InWasKayFsPlJfWNc8EoU8COjNhfo/GovFTHVjh9oUR/gwEFVwifIHihRE0Hazn2 +EQSLaOr2LM0TsRsQroFjmwSGgI+X2bfbMTqWOQIDAQABAoIBAFWZwDTeESBdrLcT +zHZe++cJLxE4AObn2LrWANEv5AeySYsyzjRBYObIN9IzrgTb8uJ900N/zVr5VkxH +xUa5PKbOcowd2NMfBTw5EEnaNbILLm+coHdanrNzVu59I9TFpAFoPavrNt/e2hNo +NMGPSdOkFi81LLl4xoadz/WR6O/7N2famM+0u7C2uBe+TrVwHyuqboYoidJDhO8M +w4WlY9QgAUhkPyzZqrl+VfF1aDTGVf4LJgaVevfFCas8Ws6DQX5q4QdIoV6/0vXi +B1M+aTnWjHuiIzjBMWhcYW2+I5zfwNWRXaxdlrYXRukGSdnyO+DH/FhHePJgmlkj +NInADDkCgYEA6MEQFOFSCc/ELXYWgStsrtIlJUcsLdLBsy1ocyQa2lkVUw58TouW +RciE6TjW9rp31pfQUnO2l6zOUC6LT9Jvlb9PSsyW+rvjtKB5PjJI6W0hjX41wEO6 +fshFELMJd9W+Ezao2AsP2hZJ8McCF8no9e00+G4xTAyxHsNI2AFTCQcCgYEA5cWZ +JwNb4t7YeEajPt9xuYNUOQpjvQn1aGOV7KcwTx5ELP/Hzi723BxHs7GSdrLkkDmi +Gpb+mfL4wxCt0fK0i8GFQsRn5eusyq9hLqP/bmjpHoXe/1uajFbE1fZQR+2LX05N +3ATlKaH2hdfCJedFa4wf43+cl6Yhp6ZA0Yet1r8CgYEAwiu1j8W9G+RRA5/8/DtO +yrUTOfsbFws4fpLGDTA0mq0whf6Soy/96C90+d9qLaC3srUpnG9eB0CpSOjbXXbv +kdxseLkexwOR3bD2FHX8r4dUM2bzznZyEaxfOaQypN8SV5ME3l60Fbr8ajqLO288 +wlTmGM5Mn+YCqOg/T7wjGmcCgYBpzNfdl/VafOROVbBbhgXWtzsz3K3aYNiIjbp+ +MunStIwN8GUvcn6nEbqOaoiXcX4/TtpuxfJMLw4OvAJdtxUdeSmEee2heCijV6g3 +ErrOOy6EqH3rNWHvlxChuP50cFQJuYOueO6QggyCyruSOnDDuc0BM0SGq6+5g5s7 +H++S/wKBgQDIkqBtFr9UEf8d6JpkxS0RXDlhSMjkXmkQeKGFzdoJcYVFIwq8jTNB +nJrVIGs3GcBkqGic+i7rTO1YPkquv4dUuiIn+vKZVoO6b54f+oPBXd4S0BnuEqFE +rdKNuCZhiaE2XD9L/O9KP1fh5bfEcKwazQ23EvpJHBMm8BGC+/YZNw== +-----END RSA PRIVATE KEY-----`)) + k, _ := x509.ParsePKCS1PrivateKey(b.Bytes) + return k +}() + +var cert = func() *x509.Certificate { + b, _ := pem.Decode([]byte(`-----BEGIN CERTIFICATE----- +MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV +BAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5 +NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8A +hs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+a +ucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWx +m+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6 +D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURN +B2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0O +BBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56 +zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5 +pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uv +NONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEf +y/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL +/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsb +GFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTL +UzreO96WzlBBMtY= +-----END CERTIFICATE-----`)) + c, _ := x509.ParseCertificate(b.Bytes) + return c +}() + +// Example from https://github.com/crewjam/saml/blob/main/example/idp/idp.go +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + schema := os.Getenv("SCHEMA") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + + baseURL, err := url.Parse(schema + "://" + host + ":" + port) + if err != nil { + + panic(err) + } + + idpServer, err := samlidp.New(samlidp.Options{ + URL: *baseURL, + Logger: logger.DefaultLogger, + Key: key, + Certificate: cert, + Store: &samlidp.MemoryStore{}, + }) + if err != nil { + + panic(err) + } + + metadata, err := xml.MarshalIndent(idpServer.IDP.Metadata(), "", " ") + if err != nil { + panic(err) + } + idpID, err := createZitadelResources(apiURL, pat, domain, metadata) + if err != nil { + panic(err) + } + + lis := bind.Socket(":" + baseURL.Port()) + goji.Handle("/*", idpServer) + + go func() { + goji.ServeListener(lis) + }() + + addService(idpServer, apiURL+"/idps/"+idpID+"/saml/metadata") + addUsers(idpServer) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + if err := lis.Close(); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func addService(idpServer *samlidp.Server, spURLStr string) { + metadataResp, err := http.Get(spURLStr) + if err != nil { + panic(err) + } + defer metadataResp.Body.Close() + + idpServer.HandlePutService( + web.C{URLParams: map[string]string{"id": spURLStr}}, + httptest.NewRecorder(), + httptest.NewRequest(http.MethodPost, spURLStr, metadataResp.Body), + ) +} + +func getSPMetadata(r io.Reader) (spMetadata *saml.EntityDescriptor, err error) { + var data []byte + if data, err = io.ReadAll(r); err != nil { + return nil, err + } + + spMetadata = &saml.EntityDescriptor{} + if err := xrv.Validate(bytes.NewBuffer(data)); err != nil { + return nil, err + } + + if err := xml.Unmarshal(data, &spMetadata); err != nil { + if err.Error() == "expected element type but have " { + entities := &saml.EntitiesDescriptor{} + if err := xml.Unmarshal(data, &entities); err != nil { + return nil, err + } + + for _, e := range entities.EntityDescriptors { + if len(e.SPSSODescriptors) > 0 { + return &e, nil + } + } + + // there were no SPSSODescriptors in the response + return nil, errors.New("metadata contained no service provider metadata") + } + + return nil, err + } + + return spMetadata, nil +} + +func addUsers(idpServer *samlidp.Server) { + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.DefaultCost) + err := idpServer.Store.Put("/users/alice", samlidp.User{Name: "alice", + HashedPassword: hashedPassword, + Groups: []string{"Administrators", "Users"}, + Email: "alice@example.com", + CommonName: "Alice Smith", + Surname: "Smith", + GivenName: "Alice", + }) + if err != nil { + panic(err) + } + + err = idpServer.Store.Put("/users/bob", samlidp.User{ + Name: "bob", + HashedPassword: hashedPassword, + Groups: []string{"Users"}, + Email: "bob@example.com", + CommonName: "Bob Smith", + Surname: "Smith", + GivenName: "Bob", + }) + if err != nil { + panic(err) + } +} + +func createZitadelResources(apiURL, pat, domain string, metadata []byte) (string, error) { + idpID, err := CreateIDP(apiURL, pat, domain, metadata) + if err != nil { + return "", err + } + return idpID, ActivateIDP(apiURL, pat, domain, idpID) +} + +type createIDP struct { + Name string `json:"name"` + MetadataXml string `json:"metadataXml"` + Binding string `json:"binding"` + WithSignedRequest bool `json:"withSignedRequest"` + ProviderOptions providerOptions `json:"providerOptions"` + NameIdFormat string `json:"nameIdFormat"` +} +type providerOptions struct { + IsLinkingAllowed bool `json:"isLinkingAllowed"` + IsCreationAllowed bool `json:"isCreationAllowed"` + IsAutoCreation bool `json:"isAutoCreation"` + IsAutoUpdate bool `json:"isAutoUpdate"` + AutoLinking string `json:"autoLinking"` +} + +type idp struct { + ID string `json:"id"` +} + +func CreateIDP(apiURL, pat, domain string, idpMetadata []byte) (string, error) { + encoded := make([]byte, base64.URLEncoding.EncodedLen(len(idpMetadata))) + base64.URLEncoding.Encode(encoded, idpMetadata) + + createIDP := &createIDP{ + Name: "CREWJAM", + MetadataXml: string(encoded), + Binding: "SAML_BINDING_REDIRECT", + WithSignedRequest: false, + ProviderOptions: providerOptions{ + IsLinkingAllowed: true, + IsCreationAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: "AUTO_LINKING_OPTION_USERNAME", + }, + NameIdFormat: "SAML_NAME_ID_FORMAT_PERSISTENT", + } + + resp, err := doRequestWithHeaders(apiURL+"/admin/v1/idps/saml", pat, domain, createIDP) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + idp := new(idp) + if err := json.Unmarshal(data, idp); err != nil { + return "", err + } + return idp.ID, nil +} + +type activateIDP struct { + IdpId string `json:"idpId"` +} + +func ActivateIDP(apiURL, pat, domain string, idpID string) error { + activateIDP := &activateIDP{ + IdpId: idpID, + } + _, err := doRequestWithHeaders(apiURL+"/admin/v1/policies/login/idps", pat, domain, activateIDP) + return err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/oidcrp/go.mod b/login/apps/login-test-acceptance/oidcrp/go.mod new file mode 100644 index 0000000000..f2cda3058e --- /dev/null +++ b/login/apps/login-test-acceptance/oidcrp/go.mod @@ -0,0 +1,26 @@ +module github.com/zitadel/typescript/acceptance/oidc + +go 1.24.1 + +require ( + github.com/google/uuid v1.6.0 + github.com/sirupsen/logrus v1.9.3 + github.com/zitadel/logging v0.6.1 + github.com/zitadel/oidc/v3 v3.36.1 +) + +require ( + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/zitadel/schema v1.3.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/login/apps/login-test-acceptance/oidcrp/go.sum b/login/apps/login-test-acceptance/oidcrp/go.sum new file mode 100644 index 0000000000..33244ea6eb --- /dev/null +++ b/login/apps/login-test-acceptance/oidcrp/go.sum @@ -0,0 +1,67 @@ +github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= +github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA= +github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zitadel/logging v0.6.1 h1:Vyzk1rl9Kq9RCevcpX6ujUaTYFX43aa4LkvV1TvUk+Y= +github.com/zitadel/logging v0.6.1/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow= +github.com/zitadel/oidc/v3 v3.36.1 h1:1AT1NqKKEqAwx4GmKJZ9fYkWH2WIn/VKMfQ46nBtRf0= +github.com/zitadel/oidc/v3 v3.36.1/go.mod h1:dApGZLvWZTHRuxmcbQlW5d2XVjVYR3vGOdq536igmTs= +github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= +github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/login/apps/login-test-acceptance/oidcrp/main.go b/login/apps/login-test-acceptance/oidcrp/main.go new file mode 100644 index 0000000000..72ae5f57e9 --- /dev/null +++ b/login/apps/login-test-acceptance/oidcrp/main.go @@ -0,0 +1,322 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "sync/atomic" + "syscall" + "time" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" + + "github.com/zitadel/logging" + "github.com/zitadel/oidc/v3/pkg/client/rp" + httphelper "github.com/zitadel/oidc/v3/pkg/http" + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +var ( + callbackPath = "/auth/callback" + key = []byte("test1234test1234") +) + +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + loginURL := os.Getenv("LOGIN_URL") + issuer := os.Getenv("ISSUER") + port := os.Getenv("PORT") + scopeList := strings.Split(os.Getenv("SCOPES"), " ") + + redirectURI := fmt.Sprintf("%s%s", issuer, callbackPath) + cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure()) + + clientID, clientSecret, err := createZitadelResources(apiURL, pat, domain, redirectURI, loginURL) + if err != nil { + panic(err) + } + + logger := slog.New( + slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelDebug, + }), + ) + client := &http.Client{ + Timeout: time.Minute, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + // enable outgoing request logging + logging.EnableHTTPClient(client, + logging.WithClientGroup("client"), + ) + + options := []rp.Option{ + rp.WithCookieHandler(cookieHandler), + rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)), + rp.WithHTTPClient(client), + rp.WithLogger(logger), + rp.WithSigningAlgsFromDiscovery(), + rp.WithCustomDiscoveryUrl(issuer + "/.well-known/openid-configuration"), + } + if clientSecret == "" { + options = append(options, rp.WithPKCE(cookieHandler)) + } + + // One can add a logger to the context, + // pre-defining log attributes as required. + ctx := logging.ToContext(context.TODO(), logger) + provider, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, redirectURI, scopeList, options...) + if err != nil { + logrus.Fatalf("error creating provider %s", err.Error()) + } + + // generate some state (representing the state of the user in your application, + // e.g. the page where he was before sending him to login + state := func() string { + return uuid.New().String() + } + + urlOptions := []rp.URLParamOpt{ + rp.WithPromptURLParam("Welcome back!"), + } + + // register the AuthURLHandler at your preferred path. + // the AuthURLHandler creates the auth request and redirects the user to the auth server. + // including state handling with secure cookie and the possibility to use PKCE. + // Prompts can optionally be set to inform the server of + // any messages that need to be prompted back to the user. + http.Handle("/login", rp.AuthURLHandler( + state, + provider, + urlOptions..., + )) + + // for demonstration purposes the returned userinfo response is written as JSON object onto response + marshalUserinfo := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) { + fmt.Println("access token", tokens.AccessToken) + fmt.Println("refresh token", tokens.RefreshToken) + fmt.Println("id token", tokens.IDToken) + + data, err := json.Marshal(info) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("content-type", "application/json") + w.Write(data) + } + + // register the CodeExchangeHandler at the callbackPath + // the CodeExchangeHandler handles the auth response, creates the token request and calls the callback function + // with the returned tokens from the token endpoint + // in this example the callback function itself is wrapped by the UserinfoCallback which + // will call the Userinfo endpoint, check the sub and pass the info into the callback function + http.Handle(callbackPath, rp.CodeExchangeHandler(rp.UserinfoCallback(marshalUserinfo), provider)) + + // if you would use the callback without calling the userinfo endpoint, simply switch the callback handler for: + // + // http.Handle(callbackPath, rp.CodeExchangeHandler(marshalToken, provider)) + + // simple counter for request IDs + var counter atomic.Int64 + // enable incomming request logging + mw := logging.Middleware( + logging.WithLogger(logger), + logging.WithGroup("server"), + logging.WithIDFunc(func() slog.Attr { + return slog.Int64("id", counter.Add(1)) + }), + ) + + http.Handle("/healthy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return })) + fmt.Println("/healthy returns 200 OK") + + server := &http.Server{ + Addr: ":" + port, + Handler: mw(http.DefaultServeMux), + } + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + log.Println("Stopped serving new connections.") + }() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownRelease() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func createZitadelResources(apiURL, pat, domain, redirectURI, loginURL string) (string, string, error) { + projectID, err := CreateProject(apiURL, pat, domain) + if err != nil { + return "", "", err + } + return CreateApp(apiURL, pat, domain, projectID, redirectURI, loginURL) +} + +type project struct { + ID string `json:"id"` +} +type createProject struct { + Name string `json:"name"` + ProjectRoleAssertion bool `json:"projectRoleAssertion"` + ProjectRoleCheck bool `json:"projectRoleCheck"` + HasProjectCheck bool `json:"hasProjectCheck"` + PrivateLabelingSetting string `json:"privateLabelingSetting"` +} + +func CreateProject(apiURL, pat, domain string) (string, error) { + createProject := &createProject{ + Name: "OIDC", + ProjectRoleAssertion: false, + ProjectRoleCheck: false, + HasProjectCheck: false, + PrivateLabelingSetting: "PRIVATE_LABELING_SETTING_UNSPECIFIED", + } + resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects", pat, domain, createProject) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + p := new(project) + if err := json.Unmarshal(data, p); err != nil { + return "", err + } + fmt.Printf("projectID: %+v\n", p.ID) + return p.ID, nil +} + +type createApp struct { + Name string `json:"name"` + RedirectUris []string `json:"redirectUris"` + ResponseTypes []string `json:"responseTypes"` + GrantTypes []string `json:"grantTypes"` + AppType string `json:"appType"` + AuthMethodType string `json:"authMethodType"` + PostLogoutRedirectUris []string `json:"postLogoutRedirectUris"` + Version string `json:"version"` + DevMode bool `json:"devMode"` + AccessTokenType string `json:"accessTokenType"` + AccessTokenRoleAssertion bool `json:"accessTokenRoleAssertion"` + IdTokenRoleAssertion bool `json:"idTokenRoleAssertion"` + IdTokenUserinfoAssertion bool `json:"idTokenUserinfoAssertion"` + ClockSkew string `json:"clockSkew"` + AdditionalOrigins []string `json:"additionalOrigins"` + SkipNativeAppSuccessPage bool `json:"skipNativeAppSuccessPage"` + BackChannelLogoutUri []string `json:"backChannelLogoutUri"` + LoginVersion version `json:"loginVersion"` +} + +type version struct { + LoginV2 loginV2 `json:"loginV2"` +} +type loginV2 struct { + BaseUri string `json:"baseUri"` +} + +type app struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` +} + +func CreateApp(apiURL, pat, domain, projectID string, redirectURI, loginURL string) (string, string, error) { + createApp := &createApp{ + Name: "OIDC", + RedirectUris: []string{redirectURI}, + ResponseTypes: []string{"OIDC_RESPONSE_TYPE_CODE"}, + GrantTypes: []string{"OIDC_GRANT_TYPE_AUTHORIZATION_CODE"}, + AppType: "OIDC_APP_TYPE_WEB", + AuthMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC", + Version: "OIDC_VERSION_1_0", + DevMode: true, + AccessTokenType: "OIDC_TOKEN_TYPE_BEARER", + AccessTokenRoleAssertion: true, + IdTokenRoleAssertion: true, + IdTokenUserinfoAssertion: true, + ClockSkew: "1s", + SkipNativeAppSuccessPage: true, + LoginVersion: version{ + LoginV2: loginV2{ + BaseUri: loginURL, + }, + }, + } + + resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects/"+projectID+"/apps/oidc", pat, domain, createApp) + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + a := new(app) + if err := json.Unmarshal(data, a); err != nil { + return "", "", err + } + return a.ClientID, a.ClientSecret, err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/package.json b/login/apps/login-test-acceptance/package.json new file mode 100644 index 0000000000..1fb83f0345 --- /dev/null +++ b/login/apps/login-test-acceptance/package.json @@ -0,0 +1,18 @@ +{ + "name": "login-test-acceptance", + "private": true, + "scripts": { + "test:acceptance": "dotenv -e ../login/.env.test.local pnpm exec playwright", + "test:acceptance:setup": "cd ../.. && make login_test_acceptance_setup_env && NODE_ENV=test pnpm exec turbo run test:acceptance:setup:dev", + "test:acceptance:setup:dev": "cd ../.. && make login_test_acceptance_setup_dev" + }, + "devDependencies": { + "@faker-js/faker": "^9.7.0", + "@otplib/core": "^12.0.0", + "@otplib/plugin-crypto": "^12.0.0", + "@otplib/plugin-thirty-two": "^12.0.0", + "@playwright/test": "^1.52.0", + "gaxios": "^7.1.0", + "typescript": "^5.8.3" + } +} diff --git a/login/apps/login-test-acceptance/pat/.gitignore b/login/apps/login-test-acceptance/pat/.gitignore new file mode 100644 index 0000000000..377ccd3fdf --- /dev/null +++ b/login/apps/login-test-acceptance/pat/.gitignore @@ -0,0 +1,2 @@ +* +!.gitkeep diff --git a/login/apps/login-test-acceptance/pat/.gitkeep b/login/apps/login-test-acceptance/pat/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/playwright-report/.gitignore b/login/apps/login-test-acceptance/playwright-report/.gitignore new file mode 100644 index 0000000000..377ccd3fdf --- /dev/null +++ b/login/apps/login-test-acceptance/playwright-report/.gitignore @@ -0,0 +1,2 @@ +* +!.gitkeep diff --git a/login/apps/login-test-acceptance/playwright-report/.gitkeep b/login/apps/login-test-acceptance/playwright-report/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/playwright.config.ts b/login/apps/login-test-acceptance/playwright.config.ts new file mode 100644 index 0000000000..8025db3238 --- /dev/null +++ b/login/apps/login-test-acceptance/playwright.config.ts @@ -0,0 +1,78 @@ +import { defineConfig, devices } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; + +dotenv.config({ path: path.resolve(__dirname, "../login/.env.test.local") }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./tests", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + expect: { + timeout: 10_000, // 10 seconds + }, + timeout: 300 * 1000, // 5 minutes + globalTimeout: 30 * 60_000, // 30 minutes + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ["line"], + ["html", { open: process.env.CI ? "never" : "on-failure", host: "0.0.0.0", outputFolder: "./playwright-report/html" }], + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.LOGIN_BASE_URL || "http://127.0.0.1:3000", + trace: "retain-on-failure", + headless: true, + screenshot: "only-on-failure", + video: "retain-on-failure", + ignoreHTTPSErrors: true, + }, + outputDir: "test-results/results", + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + /* + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + TODO: webkit fails. Is this a bug? + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + */ + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], +}); diff --git a/login/apps/login-test-acceptance/samlsp/go.mod b/login/apps/login-test-acceptance/samlsp/go.mod new file mode 100644 index 0000000000..9986149bfb --- /dev/null +++ b/login/apps/login-test-acceptance/samlsp/go.mod @@ -0,0 +1,18 @@ +module github.com/zitadel/typescript/acceptance/saml + +go 1.24.0 + +require github.com/crewjam/saml v0.4.14 + +require ( + github.com/beevik/etree v1.5.0 // indirect + github.com/crewjam/httperr v0.2.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/russellhaering/goxmldsig v1.5.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + golang.org/x/crypto v0.36.0 // indirect +) diff --git a/login/apps/login-test-acceptance/samlsp/go.sum b/login/apps/login-test-acceptance/samlsp/go.sum new file mode 100644 index 0000000000..3394a39410 --- /dev/null +++ b/login/apps/login-test-acceptance/samlsp/go.sum @@ -0,0 +1,38 @@ +github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs= +github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= +github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo= +github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4= +github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= +github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russellhaering/goxmldsig v1.5.0 h1:AU2UkkYIUOTyZRbe08XMThaOCelArgvNfYapcmSjBNw= +github.com/russellhaering/goxmldsig v1.5.0/go.mod h1:x98CjQNFJcWfMxeOrMnMKg70lvDP6tE0nTaeUnjXDmk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/login/apps/login-test-acceptance/samlsp/main.go b/login/apps/login-test-acceptance/samlsp/main.go new file mode 100644 index 0000000000..9dcfd13796 --- /dev/null +++ b/login/apps/login-test-acceptance/samlsp/main.go @@ -0,0 +1,271 @@ +package main + +import ( + "bytes" + "context" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/crewjam/saml/samlsp" +) + +var keyPair = func() tls.Certificate { + cert := []byte(`-----BEGIN CERTIFICATE----- +MIIDITCCAgmgAwIBAgIUKjAUmxsHO44X+/TKBNciPgNl1GEwDQYJKoZIhvcNAQEL +BQAwIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1wbGUuY29tMB4XDTI0MTIxOTEz +Mzc1MVoXDTI1MTIxOTEzMzc1MVowIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1w +bGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0QYuJsayILRI +hVT7G1DlitVSXnt1iw3gEXJZfe81Egz06fUbvXF6Yo1LJmwYpqe/rm+hf4FNUb8e +2O+LH2FieA9FkVe4P2gKOzw87A/KxvpV8stgNgl4LlqRCokbc1AzeE/NiLr5TcTD +RXm3DUcYxXxinprtDu2jftFysaOZmNAukvE/iL6qS3X6ggVEDDM7tY9n5FV2eJ4E +p0ImKfypi2aZYROxOK+v5x9ryFRMl4y07lMDvmtcV45uXYmfGNCgG9PNf91Kk/mh +JxEQbxycJwFoSi9XWljR8ahPdO11LXG7Dsj/RVbY8k2LdKNstl6Ae3aCpbe9u2Pj +vxYs1bVJuQIDAQABo1MwUTAdBgNVHQ4EFgQU+mRVN5HYJWgnpopReaLhf2cMcoYw +HwYDVR0jBBgwFoAU+mRVN5HYJWgnpopReaLhf2cMcoYwDwYDVR0TAQH/BAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAQEABJpHVuc9tGhD04infRVlofvqXIUizTlOrjZX +vozW9pIhSWEHX8o+sJP8AMZLnrsdq+bm0HE0HvgYrw7Lb8pd4FpR46TkFHjeukoj +izqfgckjIBl2nwPGlynbKA0/U/rTCSxVt7XiAn+lgYUGIpOzNdk06/hRMitrMNB7 +t2C97NseVC4b1ZgyFrozsefCfUmD8IJF0+XJ4Wzmsh0jRrI8koCtVmPYnKn6vw1b +cZprg/97CWHYrsavd406wOB60CMtYl83Q16ucOF1dretDFqJC5kY+aFLvuqfag2+ +kIaoPV1MnGsxveQyyHdOsEatS5XOv/1OWcmnvePDPxcvb9jCcw== +-----END CERTIFICATE----- +`) + key := []byte(`-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRBi4mxrIgtEiF +VPsbUOWK1VJee3WLDeARcll97zUSDPTp9Ru9cXpijUsmbBimp7+ub6F/gU1Rvx7Y +74sfYWJ4D0WRV7g/aAo7PDzsD8rG+lXyy2A2CXguWpEKiRtzUDN4T82IuvlNxMNF +ebcNRxjFfGKemu0O7aN+0XKxo5mY0C6S8T+IvqpLdfqCBUQMMzu1j2fkVXZ4ngSn +QiYp/KmLZplhE7E4r6/nH2vIVEyXjLTuUwO+a1xXjm5diZ8Y0KAb081/3UqT+aEn +ERBvHJwnAWhKL1daWNHxqE907XUtcbsOyP9FVtjyTYt0o2y2XoB7doKlt727Y+O/ +FizVtUm5AgMBAAECggEACak+l5f6Onj+u5vrjc4JyAaXW6ra6loSM9g8Uu3sHukW +plwoA7Pzp0u20CAxrP1Gpqw984/hSCCcb0Q2ItWMWLaC/YZni5W2WFnOyo3pzlPa +hmH4UNMT+ReCSfF/oW8w69QLcNEMjhfEu0i2iWBygIlA4SoRwC2Db6yEX7nLMwUB +6AICid9hfeACNRz/nq5ytdcHdmcB7Ptgb9jLiXr6RZw26g5AsRPHU3LdcyZAOXjP +aUHriHuHQFKAVkoEUxslvCB6ePCTCpB0bSAuzQbeGoY8fmvmNSCvJ1vrH5hiSUYp +Axtl5iNgFl5o9obb0eBYlY9x3pMSz0twdbCwfR7HAQKBgQDtWhmFm0NaJALoY+tq +lIIC0EOMSrcRIlgeXr6+g8womuDOMi5m/Nr5Mqt4mPOdP4HytrQb+a/ZmEm17KHh +mQb1vwH8ffirCBHbPNC1vwSNoxDKv9E6OysWlKiOzxPFSVZr3dKl2EMX6qi17n0l +LBrGXXaNPgYiHSmwBA5CZvvouQKBgQDhclGJfZfuoubQkUuz8yOA2uxalh/iUmQ/ +G8ac6/w7dmnL9pXehqCWh06SeC3ZvW7yrf7IIGx4sTJji2FzQ+8Ta6pPELMyBEXr +1VirIFrlNVMlMQEbZcbzdzEhchM1RUpZJtl3b4amvH21UcRB69d9klcDRisKoFRm +k0P9QLHpAQKBgQDh5J9nphZa4u0ViYtTW1XFIbs3+R/0IbCl7tww67TRbF3KQL4i +7EHna88ALumkXf3qJvKRsXgoaqS0jSqgUAjst8ZHLQkOldaQxneIkezedDSWEisp +9YgTrJYjnHefiyXB8VL63jE0wPOiewEF8Mzmv6sFz+L8cq7rQ2Di16qmmQKBgQDH +bvCwVxkrMpJK2O2GH8U9fOzu6bUE6eviY/jb4mp8U7EdjGJhuuieoM2iBoxQ/SID +rmYftYcfcWlo4+juJZ99p5W+YcCTs3IDQPUyVOnzr6uA0Avxp6RKxhsBQj+5tTUj +Dpn77P3JzB7MYqvhwPcdD3LH46+5s8FWCFpx02RPAQKBgARbngtggfifatcsMC7n +lSv/FVLH7LYQAHdoW/EH5Be7FeeP+eQvGXwh1dgl+u0VZO8FvI8RwFganpBRR2Nc +ZSBRIb0fSUlTvIsckSWjpEvUJUomJXyi4PIZAfNvd9/u1uLInQiCDtObwb6hnLTU +FHHEZ+dR4eMaJp6PhNm8hu2O +-----END PRIVATE KEY----- +`) + + kp, err := tls.X509KeyPair(cert, key) + if err != nil { + panic(err) + } + kp.Leaf, err = x509.ParseCertificate(kp.Certificate[0]) + if err != nil { + panic(err) + } + return kp +}() + +func hello(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello, %s!", samlsp.AttributeFromContext(r.Context(), "UserName")) +} + +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + loginURL := os.Getenv("LOGIN_URL") + idpURL := os.Getenv("IDP_URL") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + + idpMetadataURL, err := url.Parse(idpURL) + if err != nil { + panic(err) + } + idpMetadata, err := samlsp.FetchMetadata(context.Background(), http.DefaultClient, + *idpMetadataURL) + if err != nil { + panic(fmt.Errorf("failed to fetch IDP metadata from %s: %w", idpURL, err)) + } + fmt.Printf("idpMetadata: %+v\n", idpMetadata) + rootURL, err := url.Parse(host + ":" + port) + if err != nil { + panic(err) + } + + samlSP, err := samlsp.New(samlsp.Options{ + URL: *rootURL, + Key: keyPair.PrivateKey.(*rsa.PrivateKey), + Certificate: keyPair.Leaf, + IDPMetadata: idpMetadata, + }) + if err != nil { + panic(err) + } + + server := &http.Server{ + Addr: ":" + port, + } + app := http.HandlerFunc(hello) + http.Handle("/hello", samlSP.RequireAccount(app)) + http.Handle("/saml/", samlSP) + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + log.Println("Stopped serving new connections.") + }() + + metadata, err := xml.MarshalIndent(samlSP.ServiceProvider.Metadata(), "", " ") + if err != nil { + panic(err) + } + if err := createZitadelResources(apiURL, pat, domain, metadata, loginURL); err != nil { + panic(err) + } + + http.Handle("/healthy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return })) + fmt.Println("/healthy returns 200 OK") + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownRelease() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func createZitadelResources(apiURL, pat, domain string, metadata []byte, loginURL string) error { + projectID, err := CreateProject(apiURL, pat, domain) + if err != nil { + return err + } + return CreateApp(apiURL, pat, domain, projectID, metadata, loginURL) +} + +type project struct { + ID string `json:"id"` +} +type createProject struct { + Name string `json:"name"` + ProjectRoleAssertion bool `json:"projectRoleAssertion"` + ProjectRoleCheck bool `json:"projectRoleCheck"` + HasProjectCheck bool `json:"hasProjectCheck"` + PrivateLabelingSetting string `json:"privateLabelingSetting"` +} + +func CreateProject(apiURL, pat, domain string) (string, error) { + createProject := &createProject{ + Name: "SAML", + ProjectRoleAssertion: false, + ProjectRoleCheck: false, + HasProjectCheck: false, + PrivateLabelingSetting: "PRIVATE_LABELING_SETTING_UNSPECIFIED", + } + resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects", pat, domain, createProject) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + p := new(project) + if err := json.Unmarshal(data, p); err != nil { + return "", err + } + return p.ID, nil +} + +type createApp struct { + Name string `json:"name"` + MetadataXml string `json:"metadataXml"` + LoginVersion version `json:"loginVersion"` +} +type version struct { + LoginV2 loginV2 `json:"loginV2"` +} +type loginV2 struct { + BaseUri string `json:"baseUri"` +} + +func CreateApp(apiURL, pat, domain, projectID string, spMetadata []byte, loginURL string) error { + encoded := make([]byte, base64.URLEncoding.EncodedLen(len(spMetadata))) + base64.URLEncoding.Encode(encoded, spMetadata) + + createApp := &createApp{ + Name: "SAML", + MetadataXml: string(encoded), + LoginVersion: version{ + LoginV2: loginV2{ + BaseUri: loginURL, + }, + }, + } + _, err := doRequestWithHeaders(apiURL+"/management/v1/projects/"+projectID+"/apps/saml", pat, domain, createApp) + if err != nil { + return fmt.Errorf("error creating saml app with request %+v: %v", *createApp, err) + } + return err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/setup/go.mod b/login/apps/login-test-acceptance/setup/go.mod new file mode 100644 index 0000000000..7be166ef9b --- /dev/null +++ b/login/apps/login-test-acceptance/setup/go.mod @@ -0,0 +1,3 @@ +module github.com/zitadel/typescript/apps/login-test-acceptance/setup + +go 1.23.3 diff --git a/login/apps/login-test-acceptance/setup/go.sum b/login/apps/login-test-acceptance/setup/go.sum new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/setup/main.go b/login/apps/login-test-acceptance/setup/main.go new file mode 100644 index 0000000000..38dd16da61 --- /dev/null +++ b/login/apps/login-test-acceptance/setup/main.go @@ -0,0 +1,3 @@ +package main + +func main() {} diff --git a/login/apps/login-test-acceptance/setup/setup.sh b/login/apps/login-test-acceptance/setup/setup.sh new file mode 100755 index 0000000000..9d1a04e18f --- /dev/null +++ b/login/apps/login-test-acceptance/setup/setup.sh @@ -0,0 +1,139 @@ +#!/bin/sh + +set -e pipefail + +PAT_FILE=${PAT_FILE:-./pat/zitadel-admin-sa.pat} +LOGIN_BASE_URL=${LOGIN_BASE_URL:-"http://localhost:3000"} +ZITADEL_API_PROTOCOL="${ZITADEL_API_PROTOCOL:-http}" +ZITADEL_API_DOMAIN="${ZITADEL_API_DOMAIN:-localhost}" +ZITADEL_API_PORT="${ZITADEL_API_PORT:-8080}" +ZITADEL_API_URL="${ZITADEL_API_URL:-${ZITADEL_API_PROTOCOL}://${ZITADEL_API_DOMAIN}:${ZITADEL_API_PORT}}" +ZITADEL_API_INTERNAL_URL="${ZITADEL_API_INTERNAL_URL:-${ZITADEL_API_URL}}" +SINK_EMAIL_INTERNAL_URL="${SINK_EMAIL_INTERNAL_URL:-"http://sink:3333/email"}" +SINK_SMS_INTERNAL_URL="${SINK_SMS_INTERNAL_URL:-"http://sink:3333/sms"}" +SINK_NOTIFICATION_URL="${SINK_NOTIFICATION_URL:-"http://localhost:3333/notification"}" +WRITE_ENVIRONMENT_FILE=${WRITE_ENVIRONMENT_FILE:-$(dirname "$0")/../apps/login/.env.test.local} + +if [ -z "${PAT}" ]; then + echo "Reading PAT from file ${PAT_FILE}" + PAT=$(cat ${PAT_FILE}) +fi + +################################################################# +# ServiceAccount as Login Client +################################################################# + +SERVICEACCOUNT_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/management/v1/users/machine" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"userName\": \"login\", \"name\": \"Login v2\", \"description\": \"Serviceaccount for Login v2\", \"accessTokenType\": \"ACCESS_TOKEN_TYPE_BEARER\"}") +echo "Received ServiceAccount response: ${SERVICEACCOUNT_RESPONSE}" + +SERVICEACCOUNT_ID=$(echo ${SERVICEACCOUNT_RESPONSE} | jq -r '. | .userId') +echo "Received ServiceAccount ID: ${SERVICEACCOUNT_ID}" + +MEMBER_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/members" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"userId\": \"${SERVICEACCOUNT_ID}\", \"roles\": [\"IAM_LOGIN_CLIENT\"]}") +echo "Received Member response: ${MEMBER_RESPONSE}" + +SA_PAT_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/management/v1/users/${SERVICEACCOUNT_ID}/pats" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"expirationDate\": \"2519-04-01T08:45:00.000000Z\"}") +echo "Received Member response: ${MEMBER_RESPONSE}" + +SA_PAT=$(echo ${SA_PAT_RESPONSE} | jq -r '. | .token') +echo "Received ServiceAccount Token: ${SA_PAT}" + +################################################################# +# Environment files +################################################################# + +echo "Writing environment file ${WRITE_ENVIRONMENT_FILE}." + +echo "ZITADEL_API_URL=${ZITADEL_API_URL} +ZITADEL_SERVICE_USER_TOKEN=${SA_PAT} +ZITADEL_ADMIN_TOKEN=${PAT} +SINK_NOTIFICATION_URL=${SINK_NOTIFICATION_URL} +EMAIL_VERIFICATION=true +DEBUG=false +LOGIN_BASE_URL=${LOGIN_BASE_URL} +NODE_TLS_REJECT_UNAUTHORIZED=0 +ZITADEL_ADMIN_USER=${ZITADEL_ADMIN_USER:-"zitadel-admin@zitadel.localhost"} +NEXT_PUBLIC_BASE_PATH=/ui/v2/login +" > ${WRITE_ENVIRONMENT_FILE} + +echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}" +cat ${WRITE_ENVIRONMENT_FILE} + +################################################################# +# SMS provider with HTTP +################################################################# + +SMSHTTP_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/http" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"endpoint\": \"${SINK_SMS_INTERNAL_URL}\", \"description\": \"test\"}") +echo "Received SMS HTTP response: ${SMSHTTP_RESPONSE}" + +SMSHTTP_ID=$(echo ${SMSHTTP_RESPONSE} | jq -r '. | .id') +echo "Received SMS HTTP ID: ${SMSHTTP_ID}" + +SMS_ACTIVE_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/${SMSHTTP_ID}/_activate" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json") +echo "Received SMS active response: ${SMS_ACTIVE_RESPONSE}" + +################################################################# +# Email provider with HTTP +################################################################# + +EMAILHTTP_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/http" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"endpoint\": \"${SINK_EMAIL_INTERNAL_URL}\", \"description\": \"test\"}") +echo "Received Email HTTP response: ${EMAILHTTP_RESPONSE}" + +EMAILHTTP_ID=$(echo ${EMAILHTTP_RESPONSE} | jq -r '. | .id') +echo "Received Email HTTP ID: ${EMAILHTTP_ID}" + +EMAIL_ACTIVE_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/${EMAILHTTP_ID}/_activate" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json") +echo "Received Email active response: ${EMAIL_ACTIVE_RESPONSE}" + +################################################################# +# Wait for projection of default organization in ZITADEL +################################################################# + +DEFAULTORG_RESPONSE_RESULTS=0 +# waiting for default organization +until [ ${DEFAULTORG_RESPONSE_RESULTS} -eq 1 ] +do + DEFAULTORG_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/v2/organizations/_search" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"queries\": [{\"defaultQuery\":{}}]}" ) + echo "Received default organization response: ${DEFAULTORG_RESPONSE}" + DEFAULTORG_RESPONSE_RESULTS=$(echo $DEFAULTORG_RESPONSE | jq -r '.result | length') + echo "Received default organization response result: ${DEFAULTORG_RESPONSE_RESULTS}" +done + diff --git a/login/apps/login-test-acceptance/sink/go.mod b/login/apps/login-test-acceptance/sink/go.mod new file mode 100644 index 0000000000..1da7622b58 --- /dev/null +++ b/login/apps/login-test-acceptance/sink/go.mod @@ -0,0 +1,3 @@ +module github.com/zitadel/typescript/acceptance/sink + +go 1.24.0 diff --git a/login/apps/login-test-acceptance/sink/go.sum b/login/apps/login-test-acceptance/sink/go.sum new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/sink/main.go b/login/apps/login-test-acceptance/sink/main.go new file mode 100644 index 0000000000..f3795ba0d0 --- /dev/null +++ b/login/apps/login-test-acceptance/sink/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "net/http" +) + +type serializableData struct { + ContextInfo map[string]interface{} `json:"contextInfo,omitempty"` + Args map[string]interface{} `json:"args,omitempty"` +} + +type response struct { + Recipient string `json:"recipient,omitempty"` +} + +func main() { + port := flag.String("port", "3333", "used port for the sink") + email := flag.String("email", "/email", "path for a sent email") + emailKey := flag.String("email-key", "recipientEmailAddress", "value in the sent context info of the email used as key to retrieve the notification") + sms := flag.String("sms", "/sms", "path for a sent sms") + smsKey := flag.String("sms-key", "recipientPhoneNumber", "value in the sent context info of the sms used as key to retrieve the notification") + notification := flag.String("notification", "/notification", "path to receive the notification") + flag.Parse() + + messages := make(map[string]serializableData) + + http.HandleFunc(*email, func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + serializableData := serializableData{} + if err := json.Unmarshal(data, &serializableData); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + email, ok := serializableData.ContextInfo[*emailKey].(string) + if !ok { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + fmt.Println(email + ": " + string(data)) + messages[email] = serializableData + io.WriteString(w, "Email!\n") + }) + + http.HandleFunc(*sms, func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + serializableData := serializableData{} + if err := json.Unmarshal(data, &serializableData); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + phone, ok := serializableData.ContextInfo[*smsKey].(string) + if !ok { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + fmt.Println(phone + ": " + string(data)) + messages[phone] = serializableData + io.WriteString(w, "SMS!\n") + }) + + http.HandleFunc(*notification, func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + response := response{} + if err := json.Unmarshal(data, &response); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + msg, ok := messages[response.Recipient] + if !ok { + http.Error(w, "No messages found for recipient: "+response.Recipient, http.StatusNotFound) + return + } + serializableData, err := json.Marshal(msg) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, string(serializableData)) + }) + + fmt.Println("Starting server on", *port) + fmt.Println(*email, " for email handling") + fmt.Println(*sms, " for sms handling") + fmt.Println(*notification, " for retrieving notifications") + http.Handle("/healthy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return })) + fmt.Println("/healthy returns 200 OK") + err := http.ListenAndServe(":"+*port, nil) + if err != nil { + panic("Server could not be started: " + err.Error()) + } +} diff --git a/login/apps/login-test-acceptance/test-results/.gitignore b/login/apps/login-test-acceptance/test-results/.gitignore new file mode 100644 index 0000000000..377ccd3fdf --- /dev/null +++ b/login/apps/login-test-acceptance/test-results/.gitignore @@ -0,0 +1,2 @@ +* +!.gitkeep diff --git a/login/apps/login-test-acceptance/test-results/.gitkeep b/login/apps/login-test-acceptance/test-results/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/tests/admin.spec.ts b/login/apps/login-test-acceptance/tests/admin.spec.ts new file mode 100644 index 0000000000..13b748fc63 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/admin.spec.ts @@ -0,0 +1,7 @@ +import { test } from "@playwright/test"; +import { loginScreenExpect, loginWithPassword } from "./login"; + +test("admin login", async ({ page }) => { + await loginWithPassword(page, process.env["ZITADEL_ADMIN_USER"], "Password1!"); + await loginScreenExpect(page, "ZITADEL Admin"); +}); diff --git a/login/apps/login-test-acceptance/tests/code-screen.ts b/login/apps/login-test-acceptance/tests/code-screen.ts new file mode 100644 index 0000000000..3ab9dad26d --- /dev/null +++ b/login/apps/login-test-acceptance/tests/code-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const codeTextInput = "code-text-input"; + +export async function codeScreen(page: Page, code: string) { + await page.getByTestId(codeTextInput).pressSequentially(code); +} + +export async function codeScreenExpect(page: Page, code: string) { + await expect(page.getByTestId(codeTextInput)).toHaveValue(code); + await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify OTP code"); +} diff --git a/login/apps/login-test-acceptance/tests/code.ts b/login/apps/login-test-acceptance/tests/code.ts new file mode 100644 index 0000000000..e27d1f6150 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/code.ts @@ -0,0 +1,17 @@ +import { Page } from "@playwright/test"; +import { codeScreen } from "./code-screen"; +import { getOtpFromSink } from "./sink"; + +export async function otpFromSink(page: Page, key: string) { + const c = await getOtpFromSink(key); + await code(page, c); +} + +export async function code(page: Page, code: string) { + await codeScreen(page, code); + await page.getByTestId("submit-button").click(); +} + +export async function codeResend(page: Page) { + await page.getByTestId("resend-button").click(); +} diff --git a/login/apps/login-test-acceptance/tests/email-verify-screen.ts b/login/apps/login-test-acceptance/tests/email-verify-screen.ts new file mode 100644 index 0000000000..b077ecb424 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/email-verify-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const codeTextInput = "code-text-input"; + +export async function emailVerifyScreen(page: Page, code: string) { + await page.getByTestId(codeTextInput).pressSequentially(code); +} + +export async function emailVerifyScreenExpect(page: Page, code: string) { + await expect(page.getByTestId(codeTextInput)).toHaveValue(code); + await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify email"); +} diff --git a/login/apps/login-test-acceptance/tests/email-verify.spec.ts b/login/apps/login-test-acceptance/tests/email-verify.spec.ts new file mode 100644 index 0000000000..2c546b8eee --- /dev/null +++ b/login/apps/login-test-acceptance/tests/email-verify.spec.ts @@ -0,0 +1,69 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { emailVerify, emailVerifyResend } from "./email-verify"; +import { emailVerifyScreenExpect } from "./email-verify-screen"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { getCodeFromSink } from "./sink"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: false, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("user email not verified, verify", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + const c = await getCodeFromSink(user.getUsername()); + await emailVerify(page, c); + // wait for resend of the code + await page.waitForTimeout(2000); + await loginScreenExpect(page, user.getFullName()); +}); + +test("user email not verified, resend, verify", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + await emailVerifyResend(page); + const c = await getCodeFromSink(user.getUsername()); + // wait for resend of the code + await page.waitForTimeout(2000); + await emailVerify(page, c); + await loginScreenExpect(page, user.getFullName()); +}); + +test("user email not verified, resend, old code", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + const c = await getCodeFromSink(user.getUsername()); + await emailVerifyResend(page); + // wait for resend of the code + await page.waitForTimeout(2000); + await emailVerify(page, c); + await emailVerifyScreenExpect(page, c); +}); + +test("user email not verified, wrong code", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + const code = "wrong"; + await emailVerify(page, code); + await emailVerifyScreenExpect(page, code); +}); diff --git a/login/apps/login-test-acceptance/tests/email-verify.ts b/login/apps/login-test-acceptance/tests/email-verify.ts new file mode 100644 index 0000000000..5275e82bfe --- /dev/null +++ b/login/apps/login-test-acceptance/tests/email-verify.ts @@ -0,0 +1,15 @@ +import { Page } from "@playwright/test"; +import { emailVerifyScreen } from "./email-verify-screen"; + +export async function startEmailVerify(page: Page, loginname: string) { + await page.goto("./verify"); +} + +export async function emailVerify(page: Page, code: string) { + await emailVerifyScreen(page, code); + await page.getByTestId("submit-button").click(); +} + +export async function emailVerifyResend(page: Page) { + await page.getByTestId("resend-button").click(); +} diff --git a/login/apps/login-test-acceptance/tests/idp-apple.spec.ts b/login/apps/login-test-acceptance/tests/idp-apple.spec.ts new file mode 100644 index 0000000000..32d3adba6b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-apple.spec.ts @@ -0,0 +1,102 @@ +// Note for all tests, in case Apple doesn't deliver all relevant information per default +// We should add an action in the needed cases + +import test from "@playwright/test"; + +test("login with Apple IDP", async ({ page }) => { + test.skip(); + // Given an Apple IDP is configured on the organization + // Given the user has an Apple added as auth method + // User authenticates with Apple + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Apple IDP - error", async ({ page }) => { + test.skip(); + // Given an Apple IDP is configured on the organization + // Given the user has an Apple added as auth method + // User is redirected to Apple + // User authenticates with Apple and gets an error + // User is redirect back to login + // An error is shown to the user "Something went wrong in Apple Login" +}); + +test("login with Apple IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Apple + // User authenticates in Apple + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Apple IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Apple + // User authenticates in Apple + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Apple IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Apple + // User authenticates in Apple + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Apple IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Apple + // User authenticates in Apple with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Apple IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Apple + // User authenticates in Apple with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Apple IDP, no user linked, user link successful", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Apple + // User authenticates in Apple with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-generic-jwt.spec.ts b/login/apps/login-test-acceptance/tests/idp-generic-jwt.spec.ts new file mode 100644 index 0000000000..d68475a226 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-generic-jwt.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with Generic JWT IDP", async ({ page }) => { + test.skip(); + // Given a Generic JWT IDP is configured on the organization + // Given the user has Generic JWT IDP added as auth method + // User authenticates with the Generic JWT IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Generic JWT IDP - error", async ({ page }) => { + test.skip(); + // Given the Generic JWT IDP is configured on the organization + // Given the user has Generic JWT IDP added as auth method + // User is redirected to the Generic JWT IDP + // User authenticates with the Generic JWT IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Generic JWT IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic JWT IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic JWT IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Generic JWT IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic JWT IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Generic JWT IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-generic-oauth.spec.ts b/login/apps/login-test-acceptance/tests/idp-generic-oauth.spec.ts new file mode 100644 index 0000000000..24c25d0005 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-generic-oauth.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with Generic OAuth IDP", async ({ page }) => { + test.skip(); + // Given a Generic OAuth IDP is configured on the organization + // Given the user has Generic OAuth IDP added as auth method + // User authenticates with the Generic OAuth IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Generic OAuth IDP - error", async ({ page }) => { + test.skip(); + // Given the Generic OAuth IDP is configured on the organization + // Given the user has Generic OAuth IDP added as auth method + // User is redirected to the Generic OAuth IDP + // User authenticates with the Generic OAuth IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Generic OAuth IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OAuth IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OAuth IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Generic OAuth IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OAuth IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Generic OAuth IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-generic-oidc.spec.ts b/login/apps/login-test-acceptance/tests/idp-generic-oidc.spec.ts new file mode 100644 index 0000000000..391481f99d --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-generic-oidc.spec.ts @@ -0,0 +1,101 @@ +// Note, we should use a provider such as Google to test this, where we know OIDC standard is properly implemented + +import test from "@playwright/test"; + +test("login with Generic OIDC IDP", async ({ page }) => { + test.skip(); + // Given a Generic OIDC IDP is configured on the organization + // Given the user has Generic OIDC IDP added as auth method + // User authenticates with the Generic OIDC IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Generic OIDC IDP - error", async ({ page }) => { + test.skip(); + // Given the Generic OIDC IDP is configured on the organization + // Given the user has Generic OIDC IDP added as auth method + // User is redirected to the Generic OIDC IDP + // User authenticates with the Generic OIDC IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Generic OIDC IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OIDC IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OIDC IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Generic OIDC IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OIDC IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Generic OIDC IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-github-enterprise.spec.ts b/login/apps/login-test-acceptance/tests/idp-github-enterprise.spec.ts new file mode 100644 index 0000000000..2c39092851 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-github-enterprise.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitHub Enterprise IDP", async ({ page }) => { + test.skip(); + // Given a GitHub Enterprise IDP is configured on the organization + // Given the user has GitHub Enterprise IDP added as auth method + // User authenticates with the GitHub Enterprise IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitHub Enterprise IDP - error", async ({ page }) => { + test.skip(); + // Given the GitHub Enterprise IDP is configured on the organization + // Given the user has GitHub Enterprise IDP added as auth method + // User is redirected to the GitHub Enterprise IDP + // User authenticates with the GitHub Enterprise IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with GitHub Enterprise IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub Enterprise IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub Enterprise IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with GitHub Enterprise IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub Enterprise IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with GitHub Enterprise IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-github.spec.ts b/login/apps/login-test-acceptance/tests/idp-github.spec.ts new file mode 100644 index 0000000000..689e040537 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-github.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitHub IDP", async ({ page }) => { + test.skip(); + // Given a GitHub IDP is configured on the organization + // Given the user has GitHub IDP added as auth method + // User authenticates with the GitHub IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitHub IDP - error", async ({ page }) => { + test.skip(); + // Given the GitHub IDP is configured on the organization + // Given the user has GitHub IDP added as auth method + // User is redirected to the GitHub IDP + // User authenticates with the GitHub IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with GitHub IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to GitHub + // User authenticates in GitHub + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub + // User authenticates in GitHub + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub + // User authenticates in GitHub + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with GitHub IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to GitHub + // User authenticates in GitHub with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub + // User authenticates in GitHub with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with GitHub IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub + // User authenticates in GitHub with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-gitlab-self-hosted.spec.ts b/login/apps/login-test-acceptance/tests/idp-gitlab-self-hosted.spec.ts new file mode 100644 index 0000000000..1b05d5e19b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-gitlab-self-hosted.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitLab Self-Hosted IDP", async ({ page }) => { + test.skip(); + // Given a GitLab Self-Hosted IDP is configured on the organization + // Given the user has GitLab Self-Hosted IDP added as auth method + // User authenticates with the GitLab Self-Hosted IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitLab Self-Hosted IDP - error", async ({ page }) => { + test.skip(); + // Given the GitLab Self-Hosted IDP is configured on the organization + // Given the user has GitLab Self-Hosted IDP added as auth method + // User is redirected to the GitLab Self-Hosted IDP + // User authenticates with the GitLab Self-Hosted IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Gitlab Self-Hosted IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab Self-Hosted IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab Self-Hosted IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Gitlab Self-Hosted IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab Self-Hosted IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Gitlab Self-Hosted IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-gitlab.spec.ts b/login/apps/login-test-acceptance/tests/idp-gitlab.spec.ts new file mode 100644 index 0000000000..fdb235843b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-gitlab.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitLab IDP", async ({ page }) => { + test.skip(); + // Given a GitLab IDP is configured on the organization + // Given the user has GitLab IDP added as auth method + // User authenticates with the GitLab IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitLab IDP - error", async ({ page }) => { + test.skip(); + // Given the GitLab IDP is configured on the organization + // Given the user has GitLab IDP added as auth method + // User is redirected to the GitLab IDP + // User authenticates with the GitLab IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Gitlab IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to Gitlab + // User authenticates in Gitlab + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab + // User authenticates in Gitlab + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab + // User authenticates in Gitlab + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Gitlab IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Gitlab + // User authenticates in Gitlab with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab + // User authenticates in Gitlab with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Gitlab IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab + // User authenticates in Gitlab with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-google.spec.ts b/login/apps/login-test-acceptance/tests/idp-google.spec.ts new file mode 100644 index 0000000000..8eb4d54e34 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-google.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with Google IDP", async ({ page }) => { + test.skip(); + // Given a Google IDP is configured on the organization + // Given the user has Google IDP added as auth method + // User authenticates with the Google IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Google IDP - error", async ({ page }) => { + test.skip(); + // Given the Google IDP is configured on the organization + // Given the user has Google IDP added as auth method + // User is redirected to the Google IDP + // User authenticates with the Google IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Google IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Google + // User authenticates in Google + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Google IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Google + // User authenticates in Google + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Google IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Google + // User authenticates in Google + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Google IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Google + // User authenticates in Google with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Google IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Google + // User authenticates in Google with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Google IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Google + // User authenticates in Google with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-ldap.spec.ts b/login/apps/login-test-acceptance/tests/idp-ldap.spec.ts new file mode 100644 index 0000000000..0705ed45f8 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-ldap.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with LDAP IDP", async ({ page }) => { + test.skip(); + // Given a LDAP IDP is configured on the organization + // Given the user has LDAP IDP added as auth method + // User authenticates with the LDAP IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with LDAP IDP - error", async ({ page }) => { + test.skip(); + // Given the LDAP IDP is configured on the organization + // Given the user has LDAP IDP added as auth method + // User is redirected to the LDAP IDP + // User authenticates with the LDAP IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with LDAP IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to LDAP + // User authenticates in LDAP + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with LDAP IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to LDAP + // User authenticates in LDAP + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with LDAP IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to LDAP + // User authenticates in LDAP + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with LDAP IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to LDAP + // User authenticates in LDAP with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with LDAP IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to LDAP + // User authenticates in LDAP with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with LDAP IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to LDAP + // User authenticates in LDAP with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-microsoft.spec.ts b/login/apps/login-test-acceptance/tests/idp-microsoft.spec.ts new file mode 100644 index 0000000000..15d67c28aa --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-microsoft.spec.ts @@ -0,0 +1,102 @@ +// Note for all tests, in case Microsoft doesn't deliver all relevant information per default +// We should add an action in the needed cases + +import test from "@playwright/test"; + +test("login with Microsoft IDP", async ({ page }) => { + test.skip(); + // Given a Microsoft IDP is configured on the organization + // Given the user has Microsoft IDP added as auth method + // User authenticates with the Microsoft IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Microsoft IDP - error", async ({ page }) => { + test.skip(); + // Given the Microsoft IDP is configured on the organization + // Given the user has Microsoft IDP added as auth method + // User is redirected to the Microsoft IDP + // User authenticates with the Microsoft IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Microsoft IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Microsoft + // User authenticates in Microsoft + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Microsoft IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Microsoft + // User authenticates in Microsoft + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Microsoft IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Microsoft + // User authenticates in Microsoft + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Microsoft IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Microsoft + // User authenticates in Microsoft with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Microsoft IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Microsoft + // User authenticates in Microsoft with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Microsoft IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Microsoft + // User authenticates in Microsoft with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-saml.spec.ts b/login/apps/login-test-acceptance/tests/idp-saml.spec.ts new file mode 100644 index 0000000000..90d8d618b4 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-saml.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with SAML IDP", async ({ page }) => { + test.skip(); + // Given a SAML IDP is configured on the organization + // Given the user has SAML IDP added as auth method + // User authenticates with the SAML IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with SAML IDP - error", async ({ page }) => { + test.skip(); + // Given the SAML IDP is configured on the organization + // Given the user has SAML IDP added as auth method + // User is redirected to the SAML IDP + // User authenticates with the SAML IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with SAML IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to SAML + // User authenticates in SAML + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with SAML IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to SAML + // User authenticates in SAML + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with SAML IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to SAML + // User authenticates in SAML + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with SAML IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to SAML + // User authenticates in SAML with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with SAML IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to SAML + // User authenticates in SAML with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with SAML IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to SAML + // User authenticates in SAML with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/login-configuration-possiblities.spec.ts b/login/apps/login-test-acceptance/tests/login-configuration-possiblities.spec.ts new file mode 100644 index 0000000000..cc58dbcc71 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/login-configuration-possiblities.spec.ts @@ -0,0 +1,57 @@ +import test from "@playwright/test"; + +test("login with mfa setup, mfa setup prompt", async ({ page }) => { + test.skip(); + // Given the organization has enabled at least one mfa types + // Given the user has a password but no mfa registered + // User authenticates with login name and password + // User is prompted to setup a mfa, mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, no mfa setup prompt", async ({ page }) => { + test.skip(); + // Given the organization has set "multifactor init check time" to 0 + // Given the organization has enabled mfa types + // Given the user has a password but no mfa registered + // User authenticates with loginname and password + // user is directly loged in and not prompted to setup mfa +}); + +test("login with mfa setup, force mfa for local authenticated users", async ({ page }) => { + test.skip(); + // Given the organization has enabled force mfa for local authentiacted users + // Given the organization has enabled all possible mfa types + // Given the user has a password but no mfa registered + // User authenticates with loginname and password + // User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, force mfa - local user", async ({ page }) => { + test.skip(); + // Given the organization has enabled force mfa for local authentiacted users + // Given the organization has enabled all possible mfa types + // Given the user has a password but no mfa registered + // User authenticates with loginname and password + // User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, force mfa - external user", async ({ page }) => { + test.skip(); + // Given the organization has enabled force mfa + // Given the organization has enabled all possible mfa types + // Given the user has an idp but no mfa registered + // enter login name + // redirect to configured external idp + // User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, force mfa - local user, wrong password", async ({ page }) => { + test.skip(); + // Given the organization has a password lockout policy set to 1 on the max password attempts + // Given the user has only a password as auth methos + // enter login name + // enter wrong password + // User will get an error "Wrong password" + // enter password + // User will get an error "Max password attempts reached - user is locked. Please reach out to your administrator" +}); diff --git a/login/apps/login-test-acceptance/tests/login.ts b/login/apps/login-test-acceptance/tests/login.ts new file mode 100644 index 0000000000..2076412456 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/login.ts @@ -0,0 +1,41 @@ +import { expect, Page } from "@playwright/test"; +import { code, otpFromSink } from "./code"; +import { loginname } from "./loginname"; +import { password } from "./password"; +import { totp } from "./zitadel"; + +export async function startLogin(page: Page) { + await page.goto(`./loginname`); +} + +export async function loginWithPassword(page: Page, username: string, pw: string) { + await startLogin(page); + await loginname(page, username); + await password(page, pw); +} + +export async function loginWithPasskey(page: Page, authenticatorId: string, username: string) { + await startLogin(page); + await loginname(page, username); + // await passkey(page, authenticatorId); +} + +export async function loginScreenExpect(page: Page, fullName: string) { + await expect(page).toHaveURL(/.*signedin.*/); + await expect(page.getByRole("heading")).toContainText(fullName); +} + +export async function loginWithPasswordAndEmailOTP(page: Page, username: string, password: string, email: string) { + await loginWithPassword(page, username, password); + await otpFromSink(page, email); +} + +export async function loginWithPasswordAndPhoneOTP(page: Page, username: string, password: string, phone: string) { + await loginWithPassword(page, username, password); + await otpFromSink(page, phone); +} + +export async function loginWithPasswordAndTOTP(page: Page, username: string, password: string, secret: string) { + await loginWithPassword(page, username, password); + await code(page, totp(secret)); +} diff --git a/login/apps/login-test-acceptance/tests/loginname-screen.ts b/login/apps/login-test-acceptance/tests/loginname-screen.ts new file mode 100644 index 0000000000..be41a28eda --- /dev/null +++ b/login/apps/login-test-acceptance/tests/loginname-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const usernameTextInput = "username-text-input"; + +export async function loginnameScreen(page: Page, username: string) { + await page.getByTestId(usernameTextInput).pressSequentially(username); +} + +export async function loginnameScreenExpect(page: Page, username: string) { + await expect(page.getByTestId(usernameTextInput)).toHaveValue(username); + await expect(page.getByTestId("error").locator("div")).toContainText("User not found in the system"); +} diff --git a/login/apps/login-test-acceptance/tests/loginname.ts b/login/apps/login-test-acceptance/tests/loginname.ts new file mode 100644 index 0000000000..2050ec1d3c --- /dev/null +++ b/login/apps/login-test-acceptance/tests/loginname.ts @@ -0,0 +1,7 @@ +import { Page } from "@playwright/test"; +import { loginnameScreen } from "./loginname-screen"; + +export async function loginname(page: Page, username: string) { + await loginnameScreen(page, username); + await page.getByTestId("submit-button").click(); +} diff --git a/login/apps/login-test-acceptance/tests/passkey.ts b/login/apps/login-test-acceptance/tests/passkey.ts new file mode 100644 index 0000000000..d8cda10ddb --- /dev/null +++ b/login/apps/login-test-acceptance/tests/passkey.ts @@ -0,0 +1,109 @@ +import { expect, Page } from "@playwright/test"; +import { CDPSession } from "playwright-core"; + +interface session { + client: CDPSession; + authenticatorId: string; +} + +async function client(page: Page): Promise { + const cdpSession = await page.context().newCDPSession(page); + await cdpSession.send("WebAuthn.enable", { enableUI: false }); + const result = await cdpSession.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + return { client: cdpSession, authenticatorId: result.authenticatorId }; +} + +export async function passkeyRegister(page: Page): Promise { + const session = await client(page); + + await passkeyNotExisting(session.client, session.authenticatorId); + await simulateSuccessfulPasskeyRegister(session.client, session.authenticatorId, () => + page.getByTestId("submit-button").click(), + ); + await passkeyRegistered(session.client, session.authenticatorId); + + return session.authenticatorId; +} + +export async function passkey(page: Page, authenticatorId: string) { + const cdpSession = await page.context().newCDPSession(page); + await cdpSession.send("WebAuthn.enable", { enableUI: false }); + + const signCount = await passkeyExisting(cdpSession, authenticatorId); + + await simulateSuccessfulPasskeyInput(cdpSession, authenticatorId, () => page.getByTestId("submit-button").click()); + + await passkeyUsed(cdpSession, authenticatorId, signCount); +} + +async function passkeyNotExisting(client: CDPSession, authenticatorId: string) { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(0); +} + +async function passkeyRegistered(client: CDPSession, authenticatorId: string) { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(1); + await passkeyUsed(client, authenticatorId, 0); +} + +async function passkeyExisting(client: CDPSession, authenticatorId: string): Promise { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(1); + return result.credentials[0].signCount; +} + +async function passkeyUsed(client: CDPSession, authenticatorId: string, signCount: number) { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0].signCount).toBeGreaterThan(signCount); +} + +async function simulateSuccessfulPasskeyRegister( + client: CDPSession, + authenticatorId: string, + operationTrigger: () => Promise, +) { + // initialize event listeners to wait for a successful passkey input event + const operationCompleted = new Promise((resolve) => { + client.on("WebAuthn.credentialAdded", () => { + console.log("Credential Added!"); + resolve(); + }); + }); + + // perform a user action that triggers passkey prompt + await operationTrigger(); + + // wait to receive the event that the passkey was successfully registered or verified + await operationCompleted; +} + +async function simulateSuccessfulPasskeyInput( + client: CDPSession, + authenticatorId: string, + operationTrigger: () => Promise, +) { + // initialize event listeners to wait for a successful passkey input event + const operationCompleted = new Promise((resolve) => { + client.on("WebAuthn.credentialAsserted", () => { + console.log("Credential Asserted!"); + resolve(); + }); + }); + + // perform a user action that triggers passkey prompt + await operationTrigger(); + + // wait to receive the event that the passkey was successfully registered or verified + await operationCompleted; +} diff --git a/login/apps/login-test-acceptance/tests/password-screen.ts b/login/apps/login-test-acceptance/tests/password-screen.ts new file mode 100644 index 0000000000..fda6f6d39f --- /dev/null +++ b/login/apps/login-test-acceptance/tests/password-screen.ts @@ -0,0 +1,98 @@ +import { expect, Page } from "@playwright/test"; +import { getCodeFromSink } from "./sink"; + +const codeField = "code-text-input"; +const passwordField = "password-text-input"; +const passwordChangeField = "password-change-text-input"; +const passwordChangeConfirmField = "password-change-confirm-text-input"; +const passwordSetField = "password-set-text-input"; +const passwordSetConfirmField = "password-set-confirm-text-input"; +const lengthCheck = "length-check"; +const symbolCheck = "symbol-check"; +const numberCheck = "number-check"; +const uppercaseCheck = "uppercase-check"; +const lowercaseCheck = "lowercase-check"; +const equalCheck = "equal-check"; + +const matchText = "Matches"; +const noMatchText = "Doesn't match"; + +export async function changePasswordScreen(page: Page, password1: string, password2: string) { + await page.getByTestId(passwordChangeField).pressSequentially(password1); + await page.getByTestId(passwordChangeConfirmField).pressSequentially(password2); +} + +export async function passwordScreen(page: Page, password: string) { + await page.getByTestId(passwordField).pressSequentially(password); +} + +export async function passwordScreenExpect(page: Page, password: string) { + await expect(page.getByTestId(passwordField)).toHaveValue(password); + await expect(page.getByTestId("error").locator("div")).toContainText("Failed to authenticate."); +} + +export async function changePasswordScreenExpect( + page: Page, + password1: string, + password2: string, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { + await expect(page.getByTestId(passwordChangeField)).toHaveValue(password1); + await expect(page.getByTestId(passwordChangeConfirmField)).toHaveValue(password2); + + await checkComplexity(page, length, symbol, number, uppercase, lowercase, equals); +} + +async function checkComplexity( + page: Page, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { + await checkContent(page, lengthCheck, length); + await checkContent(page, symbolCheck, symbol); + await checkContent(page, numberCheck, number); + await checkContent(page, uppercaseCheck, uppercase); + await checkContent(page, lowercaseCheck, lowercase); + await checkContent(page, equalCheck, equals); +} + +async function checkContent(page: Page, testid: string, match: boolean) { + if (match) { + await expect(page.getByTestId(testid)).toContainText(matchText); + } else { + await expect(page.getByTestId(testid)).toContainText(noMatchText); + } +} + +export async function resetPasswordScreen(page: Page, username: string, password1: string, password2: string) { + const c = await getCodeFromSink(username); + await page.getByTestId(codeField).pressSequentially(c); + await page.getByTestId(passwordSetField).pressSequentially(password1); + await page.getByTestId(passwordSetConfirmField).pressSequentially(password2); +} + +export async function resetPasswordScreenExpect( + page: Page, + password1: string, + password2: string, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { + await expect(page.getByTestId(passwordSetField)).toHaveValue(password1); + await expect(page.getByTestId(passwordSetConfirmField)).toHaveValue(password2); + + await checkComplexity(page, length, symbol, number, uppercase, lowercase, equals); +} diff --git a/login/apps/login-test-acceptance/tests/password.ts b/login/apps/login-test-acceptance/tests/password.ts new file mode 100644 index 0000000000..ccf3e509d9 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/password.ts @@ -0,0 +1,29 @@ +import { Page } from "@playwright/test"; +import { changePasswordScreen, passwordScreen, resetPasswordScreen } from "./password-screen"; + +const passwordSubmitButton = "submit-button"; +const passwordResetButton = "reset-button"; + +export async function startChangePassword(page: Page, loginname: string) { + await page.goto("./password/change?" + new URLSearchParams({ loginName: loginname })); +} + +export async function changePassword(page: Page, password: string) { + await changePasswordScreen(page, password, password); + await page.getByTestId(passwordSubmitButton).click(); +} + +export async function password(page: Page, password: string) { + await passwordScreen(page, password); + await page.getByTestId(passwordSubmitButton).click(); +} + +export async function startResetPassword(page: Page) { + await page.getByTestId(passwordResetButton).click(); +} + +export async function resetPassword(page: Page, username: string, password: string) { + await startResetPassword(page); + await resetPasswordScreen(page, username, password, password); + await page.getByTestId(passwordSubmitButton).click(); +} diff --git a/login/apps/login-test-acceptance/tests/register-screen.ts b/login/apps/login-test-acceptance/tests/register-screen.ts new file mode 100644 index 0000000000..d14f5dc970 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/register-screen.ts @@ -0,0 +1,27 @@ +import { Page } from "@playwright/test"; + +const passwordField = "password-text-input"; +const passwordConfirmField = "password-confirm-text-input"; + +export async function registerUserScreenPassword(page: Page, firstname: string, lastname: string, email: string) { + await registerUserScreen(page, firstname, lastname, email); + await page.getByTestId("password-radio").click(); +} + +export async function registerUserScreenPasskey(page: Page, firstname: string, lastname: string, email: string) { + await registerUserScreen(page, firstname, lastname, email); + await page.getByTestId("passkey-radio").click(); +} + +export async function registerPasswordScreen(page: Page, password1: string, password2: string) { + await page.getByTestId(passwordField).pressSequentially(password1); + await page.getByTestId(passwordConfirmField).pressSequentially(password2); +} + +export async function registerUserScreen(page: Page, firstname: string, lastname: string, email: string) { + await page.getByTestId("firstname-text-input").pressSequentially(firstname); + await page.getByTestId("lastname-text-input").pressSequentially(lastname); + await page.getByTestId("email-text-input").pressSequentially(email); + await page.getByTestId("privacy-policy-checkbox").check(); + await page.getByTestId("tos-checkbox").check(); +} diff --git a/login/apps/login-test-acceptance/tests/register.spec.ts b/login/apps/login-test-acceptance/tests/register.spec.ts new file mode 100644 index 0000000000..4ad7e9e349 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/register.spec.ts @@ -0,0 +1,183 @@ +import { faker } from "@faker-js/faker"; +import { test } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect } from "./login"; +import { registerWithPasskey, registerWithPassword } from "./register"; +import { removeUserByUsername } from "./zitadel"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +test("register with password", async ({ page }) => { + const username = faker.internet.email(); + const password = "Password1!"; + const firstname = faker.person.firstName(); + const lastname = faker.person.lastName(); + + await registerWithPassword(page, firstname, lastname, username, password, password); + await loginScreenExpect(page, firstname + " " + lastname); + + // wait for projection of user + await page.waitForTimeout(10000); + await removeUserByUsername(username); +}); + +test("register with passkey", async ({ page }) => { + const username = faker.internet.email(); + const firstname = faker.person.firstName(); + const lastname = faker.person.lastName(); + + await registerWithPasskey(page, firstname, lastname, username); + await loginScreenExpect(page, firstname + " " + lastname); + + // wait for projection of user + await page.waitForTimeout(10000); + await removeUserByUsername(username); +}); + +test("register with username and password - only password enabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and "password" + // User is redirected to app (default redirect url) +}); + +test("register with username and password - wrong password not enough characters", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password thats to short + // Error is shown "Password doesn't match the policy - it must have at least 8 characters" +}); + +test("register with username and password - wrong password number missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without a number + // Error is shown "Password doesn't match the policy - number missing" +}); + +test("register with username and password - wrong password upper case missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without an upper case + // Error is shown "Password doesn't match the policy - uppercase letter missing" +}); + +test("register with username and password - wrong password lower case missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without an lower case + // Error is shown "Password doesn't match the policy - lowercase letter missing" +}); + +test("register with username and password - wrong password symboo missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without an symbol + // Error is shown "Password doesn't match the policy - symbol missing" +}); + +test("register with username and password - password and passkey enabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // User enters "firstname", "lastname", "username" + // Password and passkey are shown as authentication option + // User clicks password + // User enters password + // User is redirected to app (default redirect url) +}); + +test("register with username and passkey - password and passkey enabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // User enters "firstname", "lastname", "username" + // Password and passkey are shown as authentication option + // User clicks passkey + // Passkey is opened automatically + // User verifies passkey + // User is redirected to app (default redirect url) +}); + +test("register with username and password - registration disabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given user doesn't exist + // Button "register new user" is not available +}); + +test("register with username and password - multiple registration options", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization one idp is configured and enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration options + // Local User and idp button are shown + // User clicks idp button + // User enters "firstname", "lastname", "username" and "password" + // User clicks next + // User is redirected to app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/register.ts b/login/apps/login-test-acceptance/tests/register.ts new file mode 100644 index 0000000000..164a72753b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/register.ts @@ -0,0 +1,39 @@ +import { Page } from "@playwright/test"; +import { emailVerify } from "./email-verify"; +import { passkeyRegister } from "./passkey"; +import { registerPasswordScreen, registerUserScreenPasskey, registerUserScreenPassword } from "./register-screen"; +import { getCodeFromSink } from "./sink"; + +export async function registerWithPassword( + page: Page, + firstname: string, + lastname: string, + email: string, + password1: string, + password2: string, +) { + await page.goto("./register"); + await registerUserScreenPassword(page, firstname, lastname, email); + await page.getByTestId("submit-button").click(); + await registerPasswordScreen(page, password1, password2); + await page.getByTestId("submit-button").click(); + await verifyEmail(page, email); +} + +export async function registerWithPasskey(page: Page, firstname: string, lastname: string, email: string): Promise { + await page.goto("./register"); + await registerUserScreenPasskey(page, firstname, lastname, email); + await page.getByTestId("submit-button").click(); + + // wait for projection of user + await page.waitForTimeout(10000); + const authId = await passkeyRegister(page); + + await verifyEmail(page, email); + return authId; +} + +async function verifyEmail(page: Page, email: string) { + const c = await getCodeFromSink(email); + await emailVerify(page, c); +} diff --git a/login/apps/login-test-acceptance/tests/select-account.ts b/login/apps/login-test-acceptance/tests/select-account.ts new file mode 100644 index 0000000000..64bd7cd145 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/select-account.ts @@ -0,0 +1,5 @@ +import { Page } from "@playwright/test"; + +export async function selectNewAccount(page: Page) { + await page.getByRole("link", { name: "Add another account" }).click(); +} diff --git a/login/apps/login-test-acceptance/tests/sink.ts b/login/apps/login-test-acceptance/tests/sink.ts new file mode 100644 index 0000000000..bc3336b358 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/sink.ts @@ -0,0 +1,43 @@ +import { Gaxios, GaxiosResponse } from "gaxios"; + +const awaitNotification = new Gaxios({ + url: process.env.SINK_NOTIFICATION_URL, + method: "POST", + retryConfig: { + httpMethodsToRetry: ["POST"], + statusCodesToRetry: [[404, 404]], + retry: Number.MAX_SAFE_INTEGER, // totalTimeout limits the number of retries + totalTimeout: 10000, // 10 seconds + onRetryAttempt: (error) => { + console.warn(`Retrying request to sink notification service: ${error.message}`); + }, + }, +}); + +export async function getOtpFromSink(recipient: string): Promise { + return awaitNotification.request({ data: { recipient } }).then((response) => { + expectSuccess(response); + const otp = response?.data?.args?.otp; + if (!otp) { + throw new Error(`Response does not contain an otp property: ${JSON.stringify(response.data, null, 2)}`); + } + return otp; + }); +} + +export async function getCodeFromSink(recipient: string): Promise { + return awaitNotification.request({ data: { recipient } }).then((response) => { + expectSuccess(response); + const code = response?.data?.args?.code; + if (!code) { + throw new Error(`Response does not contain a code property: ${JSON.stringify(response.data, null, 2)}`); + } + return code; + }); +} + +function expectSuccess(response: GaxiosResponse): void { + if (response.status !== 200) { + throw new Error(`Expected HTTP status 200, but got: ${response.status} - ${response.statusText}`); + } +} diff --git a/login/apps/login-test-acceptance/tests/user.ts b/login/apps/login-test-acceptance/tests/user.ts new file mode 100644 index 0000000000..3b03291408 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/user.ts @@ -0,0 +1,177 @@ +import { Page } from "@playwright/test"; +import { registerWithPasskey } from "./register"; +import { activateOTP, addTOTP, addUser, eventualNewUser, getUserByUsername, removeUser } from "./zitadel"; + +export interface userProps { + email: string; + isEmailVerified?: boolean; + firstName: string; + lastName: string; + organization: string; + password: string; + passwordChangeRequired?: boolean; + phone: string; + isPhoneVerified?: boolean; +} + +class User { + private readonly props: userProps; + private user: string; + + constructor(userProps: userProps) { + this.props = userProps; + } + + async ensure(page: Page) { + const response = await addUser(this.props); + + this.setUserId(response.userId); + } + + async cleanup() { + await removeUser(this.getUserId()); + } + + public setUserId(userId: string) { + this.user = userId; + } + + public getUserId() { + return this.user; + } + + public getUsername() { + return this.props.email; + } + + public getPassword() { + return this.props.password; + } + + public getFirstname() { + return this.props.firstName; + } + + public getLastname() { + return this.props.lastName; + } + + public getPhone() { + return this.props.phone; + } + + public getFullName() { + return `${this.props.firstName} ${this.props.lastName}`; + } +} + +export class PasswordUser extends User { + async ensure(page: Page) { + await super.ensure(page); + await eventualNewUser(this.getUserId()); + } +} + +export enum OtpType { + sms = "sms", + email = "email", +} + +export interface otpUserProps { + email: string; + isEmailVerified?: boolean; + firstName: string; + lastName: string; + organization: string; + password: string; + passwordChangeRequired?: boolean; + phone: string; + isPhoneVerified?: boolean; + type: OtpType; +} + +export class PasswordUserWithOTP extends User { + private type: OtpType; + + constructor(props: otpUserProps) { + super({ + email: props.email, + firstName: props.firstName, + lastName: props.lastName, + organization: props.organization, + password: props.password, + phone: props.phone, + isEmailVerified: props.isEmailVerified, + isPhoneVerified: props.isPhoneVerified, + passwordChangeRequired: props.passwordChangeRequired, + }); + this.type = props.type; + } + + async ensure(page: Page) { + await super.ensure(page); + await activateOTP(this.getUserId(), this.type); + await eventualNewUser(this.getUserId()); + } +} + +export class PasswordUserWithTOTP extends User { + private secret: string; + + async ensure(page: Page) { + await super.ensure(page); + this.secret = await addTOTP(this.getUserId()); + await eventualNewUser(this.getUserId()); + } + + public getSecret(): string { + return this.secret; + } +} + +export interface passkeyUserProps { + email: string; + firstName: string; + lastName: string; + organization: string; + phone: string; + isEmailVerified?: boolean; + isPhoneVerified?: boolean; +} + +export class PasskeyUser extends User { + private authenticatorId: string; + + constructor(props: passkeyUserProps) { + super({ + email: props.email, + firstName: props.firstName, + lastName: props.lastName, + organization: props.organization, + password: "", + phone: props.phone, + isEmailVerified: props.isEmailVerified, + isPhoneVerified: props.isPhoneVerified, + }); + } + + public async ensure(page: Page) { + const authId = await registerWithPasskey(page, this.getFirstname(), this.getLastname(), this.getUsername()); + this.authenticatorId = authId; + + // wait for projection of user + await page.waitForTimeout(10000); + } + + async cleanup() { + const resp: any = await getUserByUsername(this.getUsername()); + if (!resp || !resp.result || !resp.result[0]) { + return; + } + await removeUser(resp.result[0].userId); + } + + public getAuthenticatorId(): string { + return this.authenticatorId; + } +} diff --git a/login/apps/login-test-acceptance/tests/username-passkey.spec.ts b/login/apps/login-test-acceptance/tests/username-passkey.spec.ts new file mode 100644 index 0000000000..dff1c65f5a --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-passkey.spec.ts @@ -0,0 +1,43 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPasskey } from "./login"; +import { PasskeyUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasskeyUser }>({ + user: async ({ page }, use) => { + const user = new PasskeyUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and passkey login", async ({ user, page }) => { + await loginWithPasskey(page, user.getAuthenticatorId(), user.getUsername()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username and passkey login, multiple auth methods", async ({ page }) => { + test.skip(); + // Given passkey and password is enabled on the organization of the user + // Given the user has password and passkey registered + // enter username + // passkey popup is directly shown + // user aborts passkey authentication + // user switches to password authentication + // user enters password + // user is redirected to app +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-change-required.spec.ts b/login/apps/login-test-acceptance/tests/username-password-change-required.spec.ts new file mode 100644 index 0000000000..50605e5ff0 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-change-required.spec.ts @@ -0,0 +1,41 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { changePassword } from "./password"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: true, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password login, change required", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await page.waitForTimeout(10000); + await changePassword(page, changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-changed.spec.ts b/login/apps/login-test-acceptance/tests/username-password-changed.spec.ts new file mode 100644 index 0000000000..dc29dc2286 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-changed.spec.ts @@ -0,0 +1,54 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { changePassword, startChangePassword } from "./password"; +import { changePasswordScreen, changePasswordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password changed login", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + + // wait for projection of token + await page.waitForTimeout(10000); + + await startChangePassword(page, user.getUsername()); + await changePassword(page, changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); + +test("password change not with desired complexity", async ({ user, page }) => { + const changedPw1 = "change"; + const changedPw2 = "chang"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await startChangePassword(page, user.getUsername()); + await changePasswordScreen(page, changedPw1, changedPw2); + await changePasswordScreenExpect(page, changedPw1, changedPw2, false, false, false, false, true, false); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-otp_email.spec.ts b/login/apps/login-test-acceptance/tests/username-password-otp_email.spec.ts new file mode 100644 index 0000000000..e4a77751c1 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-otp_email.spec.ts @@ -0,0 +1,98 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { code, codeResend, otpFromSink } from "./code"; +import { codeScreenExpect } from "./code-screen"; +import { loginScreenExpect, loginWithPassword, loginWithPasswordAndEmailOTP } from "./login"; +import { OtpType, PasswordUserWithOTP } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({ + user: async ({ page }, use) => { + const user = new PasswordUserWithOTP({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + type: OtpType.email, + }); + + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test.skip("DOESN'T WORK: username, password and email otp login, enter code manually", async ({ user, page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User enters the code into the ui + // User is redirected to the app (default redirect url) + await loginWithPasswordAndEmailOTP(page, user.getUsername(), user.getPassword(), user.getUsername()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and email otp login, click link in email", async ({ page }) => { + base.skip(); + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User clicks link in the email + // User is redirected to the app (default redirect url) +}); + +test.skip("DOESN'T WORK: username, password and email otp login, resend code", async ({ user, page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User clicks resend code + // User receives a new email with a verification code + // User enters the new code in the ui + // User is redirected to the app (default redirect url) + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await codeResend(page); + await otpFromSink(page, user.getUsername()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and email otp login, wrong code", async ({ user, page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User enters a wrong code + // Error message - "Invalid code" is shown + const c = "wrongcode"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await code(page, c); + await codeScreenExpect(page, c); +}); + +test("username, password and email otp login, multiple mfa options", async ({ page }) => { + base.skip(); + // Given email otp and sms otp is enabled on the organization of the user + // Given the user has email and sms otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User clicks button to use sms otp as second factor + // User receives a sms with a verification code + // User enters code in ui + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-otp_sms.spec.ts b/login/apps/login-test-acceptance/tests/username-password-otp_sms.spec.ts new file mode 100644 index 0000000000..10901cd243 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-otp_sms.spec.ts @@ -0,0 +1,71 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { code } from "./code"; +import { codeScreenExpect } from "./code-screen"; +import { loginScreenExpect, loginWithPassword, loginWithPasswordAndPhoneOTP } from "./login"; +import { OtpType, PasswordUserWithOTP } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({ + user: async ({ page }, use) => { + const user = new PasswordUserWithOTP({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number({ style: "international" }), + isPhoneVerified: true, + password: "Password1!", + passwordChangeRequired: false, + type: OtpType.sms, + }); + + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test.skip("DOESN'T WORK: username, password and sms otp login, enter code manually", async ({ user, page }) => { + // Given sms otp is enabled on the organization of the user + // Given the user has only sms otp configured as second factor + // User enters username + // User enters password + // User receives a sms with a verification code + // User enters the code into the ui + // User is redirected to the app (default redirect url) + await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone()); + await loginScreenExpect(page, user.getFullName()); +}); + +test.skip("DOESN'T WORK: username, password and sms otp login, resend code", async ({ user, page }) => { + // Given sms otp is enabled on the organization of the user + // Given the user has only sms otp configured as second factor + // User enters username + // User enters password + // User receives a sms with a verification code + // User clicks resend code + // User receives a new sms with a verification code + // User is redirected to the app (default redirect url) + await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and sms otp login, wrong code", async ({ user, page }) => { + // Given sms otp is enabled on the organization of the user + // Given the user has only sms otp configured as second factor + // User enters username + // User enters password + // User receives a sms with a verification code + // User enters a wrong code + // Error message - "Invalid code" is shown + const c = "wrongcode"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await code(page, c); + await codeScreenExpect(page, c); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-set.spec.ts b/login/apps/login-test-acceptance/tests/username-password-set.spec.ts new file mode 100644 index 0000000000..06ce42f1a7 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-set.spec.ts @@ -0,0 +1,52 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword, startLogin } from "./login"; +import { loginname } from "./loginname"; +import { resetPassword, startResetPassword } from "./password"; +import { resetPasswordScreen, resetPasswordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password set login", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + await startLogin(page); + await loginname(page, user.getUsername()); + await resetPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); + +test("password set not with desired complexity", async ({ user, page }) => { + const changedPw1 = "change"; + const changedPw2 = "chang"; + await startLogin(page); + await loginname(page, user.getUsername()); + await startResetPassword(page); + await resetPasswordScreen(page, user.getUsername(), changedPw1, changedPw2); + await resetPasswordScreenExpect(page, changedPw1, changedPw2, false, false, false, false, true, false); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-totp.spec.ts b/login/apps/login-test-acceptance/tests/username-password-totp.spec.ts new file mode 100644 index 0000000000..e495b16681 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-totp.spec.ts @@ -0,0 +1,71 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { code } from "./code"; +import { codeScreenExpect } from "./code-screen"; +import { loginScreenExpect, loginWithPassword, loginWithPasswordAndTOTP } from "./login"; +import { PasswordUserWithTOTP } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUserWithTOTP; sink: any }>({ + user: async ({ page }, use) => { + const user = new PasswordUserWithTOTP({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number({ style: "international" }), + isPhoneVerified: true, + password: "Password1!", + passwordChangeRequired: false, + }); + + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username, password and totp login", async ({ user, page }) => { + // Given totp is enabled on the organization of the user + // Given the user has only totp configured as second factor + // User enters username + // User enters password + // Screen for entering the code is shown directly + // User enters the code into the ui + // User is redirected to the app (default redirect url) + await loginWithPasswordAndTOTP(page, user.getUsername(), user.getPassword(), user.getSecret()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and totp otp login, wrong code", async ({ user, page }) => { + // Given totp is enabled on the organization of the user + // Given the user has only totp configured as second factor + // User enters username + // User enters password + // Screen for entering the code is shown directly + // User enters a wrond code + // Error message - "Invalid code" is shown + const c = "wrongcode"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await code(page, c); + await codeScreenExpect(page, c); +}); + +test("username, password and totp login, multiple mfa options", async ({ page }) => { + test.skip(); + // Given totp and email otp is enabled on the organization of the user + // Given the user has totp and email otp configured as second factor + // User enters username + // User enters password + // Screen for entering the code is shown directly + // Button to switch to email otp is shown + // User clicks button to use email otp instead + // User receives an email with a verification code + // User enters code in ui + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-u2f.spec.ts b/login/apps/login-test-acceptance/tests/username-password-u2f.spec.ts new file mode 100644 index 0000000000..dc23064fd6 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-u2f.spec.ts @@ -0,0 +1,26 @@ +import { test } from "@playwright/test"; + +test("username, password and u2f login", async ({ page }) => { + test.skip(); + // Given u2f is enabled on the organization of the user + // Given the user has only u2f configured as second factor + // User enters username + // User enters password + // Popup for u2f is directly opened + // User verifies u2f + // User is redirected to the app (default redirect url) +}); + +test("username, password and u2f login, multiple mfa options", async ({ page }) => { + test.skip(); + // Given u2f and semailms otp is enabled on the organization of the user + // Given the user has u2f and email otp configured as second factor + // User enters username + // User enters password + // Popup for u2f is directly opened + // User aborts u2f verification + // User clicks button to use email otp as second factor + // User receives an email with a verification code + // User enters code in ui + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/username-password.spec.ts b/login/apps/login-test-acceptance/tests/username-password.spec.ts new file mode 100644 index 0000000000..ceb340f8da --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password.spec.ts @@ -0,0 +1,157 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword, startLogin } from "./login"; +import { loginname } from "./loginname"; +import { loginnameScreenExpect } from "./loginname-screen"; +import { password } from "./password"; +import { passwordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password login", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username and password login, unknown username", async ({ page }) => { + const username = "unknown"; + await startLogin(page); + await loginname(page, username); + await loginnameScreenExpect(page, username); +}); + +test("username and password login, wrong password", async ({ user, page }) => { + await startLogin(page); + await loginname(page, user.getUsername()); + await password(page, "wrong"); + await passwordScreenExpect(page, "wrong"); +}); + +test("username and password login, wrong username, ignore unknown usernames", async ({ user, page }) => { + test.skip(); + // Given user doesn't exist but ignore unknown usernames setting is set to true + // Given username password login is enabled on the users organization + // enter login name + // enter password + // redirect to loginname page --> error message username or password wrong +}); + +test("username and password login, initial password change", async ({ user, page }) => { + test.skip(); + // Given user is created and has changePassword set to true + // Given username password login is enabled on the users organization + // enter login name + // enter password + // create new password +}); + +test("username and password login, reset password hidden", async ({ user, page }) => { + test.skip(); + // Given the organization has enabled "Password reset hidden" in the login policy + // Given username password login is enabled on the users organization + // enter login name + // password reset link should not be shown on password screen +}); + +test("username and password login, reset password - enter code manually", async ({ user, page }) => { + test.skip(); + // Given user has forgotten password and clicks the forgot password button + // Given username password login is enabled on the users organization + // enter login name + // click password forgotten + // enter code from email + // user is redirected to app (default redirect url) +}); + +test("username and password login, reset password - click link", async ({ user, page }) => { + test.skip(); + // Given user has forgotten password and clicks the forgot password button, and then the link in the email + // Given username password login is enabled on the users organization + // enter login name + // click password forgotten + // click link in email + // set new password + // redirect to app (default redirect url) +}); + +test("username and password login, reset password, resend code", async ({ user, page }) => { + test.skip(); + // Given user has forgotten password and clicks the forgot password button and then resend code + // Given username password login is enabled on the users organization + // enter login name + // click password forgotten + // click resend code + // enter code from second email + // user is redirected to app (default redirect url) +}); + +test("email login enabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same email address exists + // enter email address "test@zitadel.com " in login screen + // user will get to password screen +}); + +test("email login disabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same email address exists + // enter email address "test@zitadel.com" in login screen + // user will see error message "user not found" +}); + +test("email login enabled - multiple users", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given a second user with the username "testuser2", email test@zitadel.com and phone number 0711111111 exists + // enter email address "test@zitadel.com" in login screen + // user will see error message "user not found" +}); + +test("phone login enabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same phon number exists + // enter phone number "0711111111" in login screen + // user will get to password screen +}); + +test("phone login disabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same phone number exists + // enter phone number "0711111111" in login screen + // user will see error message "user not found" +}); + +test("phone login enabled - multiple users", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given a second user with the username "testuser2", email test@zitadel.com and phone number 0711111111 exists + // enter phone number "0711111111" in login screen + // user will see error message "user not found" +}); diff --git a/login/apps/login-test-acceptance/tests/welcome.ts b/login/apps/login-test-acceptance/tests/welcome.ts new file mode 100644 index 0000000000..34267c2bd0 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/welcome.ts @@ -0,0 +1,6 @@ +import { test } from "@playwright/test"; + +test("login is accessible", async ({ page }) => { + await page.goto("./"); + await page.getByRole("heading", { name: "Welcome back!" }).isVisible(); +}); diff --git a/login/apps/login-test-acceptance/tests/zitadel.ts b/login/apps/login-test-acceptance/tests/zitadel.ts new file mode 100644 index 0000000000..b252654f86 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/zitadel.ts @@ -0,0 +1,190 @@ +import { Authenticator } from "@otplib/core"; +import { createDigest, createRandomBytes } from "@otplib/plugin-crypto"; +import { keyDecoder, keyEncoder } from "@otplib/plugin-thirty-two"; // use your chosen base32 plugin +import axios from "axios"; +import dotenv from "dotenv"; +import { request } from "gaxios"; +import path from "path"; +import { OtpType, userProps } from "./user"; + +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +export async function addUser(props: userProps) { + const body = { + username: props.email, + organization: { + orgId: props.organization, + }, + profile: { + givenName: props.firstName, + familyName: props.lastName, + }, + email: { + email: props.email, + isVerified: true, + }, + phone: { + phone: props.phone, + isVerified: true, + }, + password: { + password: props.password, + changeRequired: props.passwordChangeRequired ?? false, + }, + }; + if (!props.isEmailVerified) { + delete body.email.isVerified; + } + if (!props.isPhoneVerified) { + delete body.phone.isVerified; + } + + return await listCall(`${process.env.ZITADEL_API_URL}/v2/users/human`, body); +} + +export async function removeUserByUsername(username: string) { + const resp = await getUserByUsername(username); + if (!resp || !resp.result || !resp.result[0]) { + return; + } + await removeUser(resp.result[0].userId); +} + +export async function removeUser(id: string) { + await deleteCall(`${process.env.ZITADEL_API_URL}/v2/users/${id}`); +} + +async function deleteCall(url: string) { + try { + const response = await axios.delete(url, { + headers: { + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + }, + }); + + if (response.status >= 400 && response.status !== 404) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function getUserByUsername(username: string): Promise { + const listUsersBody = { + queries: [ + { + userNameQuery: { + userName: username, + }, + }, + ], + }; + + return await listCall(`${process.env.ZITADEL_API_URL}/v2/users`, listUsersBody); +} + +async function listCall(url: string, data: any): Promise { + try { + const response = await axios.post(url, data, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + }, + }); + + if (response.status >= 400) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + + return response.data; + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function activateOTP(userId: string, type: OtpType) { + let url = "otp_"; + switch (type) { + case OtpType.sms: + url = url + "sms"; + break; + case OtpType.email: + url = url + "email"; + break; + } + + await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/${url}`, {}); +} + +async function pushCall(url: string, data: any) { + try { + const response = await axios.post(url, data, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + }, + }); + + if (response.status >= 400) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function addTOTP(userId: string): Promise { + const response = await listCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp`, {}); + const code = totp(response.secret); + await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp/verify`, { code: code }); + return response.secret; +} + +export function totp(secret: string) { + const authenticator = new Authenticator({ + createDigest, + createRandomBytes, + keyDecoder, + keyEncoder, + }); + // google authenticator usage + const token = authenticator.generate(secret); + + // check if token can be used + if (!authenticator.verify({ token: token, secret: secret })) { + const error = `Generated token could not be verified`; + console.error(error); + throw new Error(error); + } + + return token; +} + +export async function eventualNewUser(id: string) { + return request({ + url: `${process.env.ZITADEL_API_URL}/v2/users/${id}`, + method: "GET", + headers: { + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + "Content-Type": "application/json", + }, + retryConfig: { + statusCodesToRetry: [[404, 404]], + retry: Number.MAX_SAFE_INTEGER, // totalTimeout limits the number of retries + totalTimeout: 10000, // 10 seconds + onRetryAttempt: (error) => { + console.warn(`Retrying to query new user ${id}: ${error.message}`); + }, + }, + }); +} diff --git a/login/apps/login-test-acceptance/turbo.json b/login/apps/login-test-acceptance/turbo.json new file mode 100644 index 0000000000..3be0539d0f --- /dev/null +++ b/login/apps/login-test-acceptance/turbo.json @@ -0,0 +1,10 @@ +{ + "extends": ["//"], + "tasks": { + "test:acceptance:setup:dev": { + "interactive": true, + "cache": false, + "persistent": true + } + } +} diff --git a/login/apps/login-test-acceptance/zitadel.yaml b/login/apps/login-test-acceptance/zitadel.yaml new file mode 100644 index 0000000000..3ddeaf67f0 --- /dev/null +++ b/login/apps/login-test-acceptance/zitadel.yaml @@ -0,0 +1,83 @@ +ExternalDomain: 127.0.0.1.sslip.io +ExternalSecure: true +ExternalPort: 443 +TLS.Enabled: false + +FirstInstance: + PatPath: /pat/zitadel-admin-sa.pat + Org: + Human: + UserName: zitadel-admin + FirstName: ZITADEL + LastName: Admin + Password: Password1! + PasswordChangeRequired: false + PreferredLanguage: en + Machine: + Machine: + Username: zitadel-admin-sa + Name: Admin + Pat: + ExpirationDate: 2099-01-01T00:00:00Z + +DefaultInstance: + LoginPolicy: + AllowUsernamePassword: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWUSERNAMEPASSWORD + AllowRegister: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWREGISTER + AllowExternalIDP: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWEXTERNALIDP + ForceMFA: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_FORCEMFA + HidePasswordReset: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_HIDEPASSWORDRESET + IgnoreUnknownUsernames: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_IGNOREUNKNOWNUSERNAMES + AllowDomainDiscovery: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWDOMAINDISCOVERY + # 1 is allowed, 0 is not allowed + PasswordlessType: 1 # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_PASSWORDLESSTYPE + # DefaultRedirectURL is empty by default because we use the Console UI + DefaultRedirectURI: # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_DEFAULTREDIRECTURI + # 240h = 10d + PasswordCheckLifetime: 240h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_PASSWORDCHECKLIFETIME + # 240h = 10d + ExternalLoginCheckLifetime: 240h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_EXTERNALLOGINCHECKLIFETIME + # 720h = 30d + MfaInitSkipLifetime: 0h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_MFAINITSKIPLIFETIME + SecondFactorCheckLifetime: 18h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_SECONDFACTORCHECKLIFETIME + MultiFactorCheckLifetime: 12h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_MULTIFACTORCHECKLIFETIME + PrivacyPolicy: + TOSLink: "https://zitadel.com/docs/legal/terms-of-service" + PrivacyLink: "https://zitadel.com/docs/legal/policies/privacy-policy" + HelpLink: "https://zitadel.com/docs" + SupportEmail: "support@zitadel.com" + DocsLink: "https://zitadel.com/docs" + Features: + LoginV2: + Required: true + +OIDC: + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" + +SAML: + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" + +Database: + EventPushConnRatio: 0.2 # 4 + ProjectionSpoolerConnRatio: 0.3 # 6 + postgres: + Host: db + Port: 5432 + Database: zitadel + MaxOpenConns: 20 + MaxIdleConns: 20 + MaxConnLifetime: 1h + MaxConnIdleTime: 5m + User: + Username: zitadel + SSL: + Mode: disable + Admin: + Username: zitadel + SSL: + Mode: disable + +Logstore: + Access: + Stdout: + Enabled: true diff --git a/login/apps/login-test-integration/.gitignore b/login/apps/login-test-integration/.gitignore new file mode 100644 index 0000000000..2ca81ab137 --- /dev/null +++ b/login/apps/login-test-integration/.gitignore @@ -0,0 +1,2 @@ +screenshots +videos \ No newline at end of file diff --git a/login/apps/login-test-integration/core-mock/Dockerfile b/login/apps/login-test-integration/core-mock/Dockerfile new file mode 100644 index 0000000000..469147d17d --- /dev/null +++ b/login/apps/login-test-integration/core-mock/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1.20.5-alpine3.18 + +RUN go install github.com/eliobischof/grpc-mock/cmd/grpc-mock@01b09f60db1b501178af59bed03b2c22661df48c + +COPY mocked-services.cfg . +COPY initial-stubs initial-stubs +COPY --from=protos . . + +ENTRYPOINT [ "sh", "-c", "grpc-mock -v 1 -proto $(tr '\n' ',' < ./mocked-services.cfg) -stub-dir ./initial-stubs -mock-addr :22222" ] diff --git a/login/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json b/login/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json new file mode 100644 index 0000000000..3da4ae999f --- /dev/null +++ b/login/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json @@ -0,0 +1,59 @@ +[ + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetBrandingSettings", + "out": { + "data": {} + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetSecuritySettings", + "out": { + "data": {} + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetLegalAndSupportSettings", + "out": { + "data": { + "settings": { + "tosLink": "http://whatever.com/help", + "privacyPolicyLink": "http://whatever.com/help", + "helpLink": "http://whatever.com/help" + } + } + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetActiveIdentityProviders", + "out": { + "data": { + "identityProviders": [ + { + "id": "123", + "name": "Hubba bubba", + "type": 10 + } + ] + } + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetPasswordComplexitySettings", + "out": { + "data": { + "settings": { + "minLength": 8, + "requiresUppercase": true, + "requiresLowercase": true, + "requiresNumber": true, + "requiresSymbol": true + } + } + } + } +] diff --git a/login/apps/login-test-integration/core-mock/mocked-services.cfg b/login/apps/login-test-integration/core-mock/mocked-services.cfg new file mode 100644 index 0000000000..6a758ab8c1 --- /dev/null +++ b/login/apps/login-test-integration/core-mock/mocked-services.cfg @@ -0,0 +1,7 @@ +zitadel/user/v2/user_service.proto +zitadel/org/v2/org_service.proto +zitadel/session/v2/session_service.proto +zitadel/settings/v2/settings_service.proto +zitadel/management.proto +zitadel/auth.proto +zitadel/admin.proto \ No newline at end of file diff --git a/login/apps/login-test-integration/cypress.config.ts b/login/apps/login-test-integration/cypress.config.ts new file mode 100644 index 0000000000..080cb31bc6 --- /dev/null +++ b/login/apps/login-test-integration/cypress.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "cypress"; + +export default defineConfig({ + reporter: "list", + + e2e: { + baseUrl: process.env.LOGIN_BASE_URL || "http://localhost:3000", + specPattern: "integration/**/*.cy.{js,jsx,ts,tsx}", + supportFile: "support/e2e.{js,jsx,ts,tsx}", + setupNodeEvents(on, config) { + // implement node event listeners here + }, + }, +}); diff --git a/login/apps/login-test-integration/docker-compose.yaml b/login/apps/login-test-integration/docker-compose.yaml new file mode 100644 index 0000000000..2f09a2253e --- /dev/null +++ b/login/apps/login-test-integration/docker-compose.yaml @@ -0,0 +1,30 @@ +services: + core-mock: + image: "${LOGIN_CORE_MOCK_TAG:-login-core-mock:local}" + container_name: integration-core-mock + ports: + - 22220:22220 + - 22222:22222 + + login: + image: "${LOGIN_TAG:-login:local}" + container_name: integration-login + ports: + - 3001:3001 + environment: + - PORT=3001 + - ZITADEL_API_URL=http://core-mock:22222 + - ZITADEL_SERVICE_USER_TOKEN="yolo" + - EMAIL_VERIFICATION=true + + integration: + image: "${LOGIN_TEST_INTEGRATION_TAG:-login-test-integration:local}" + container_name: integration + environment: + - LOGIN_BASE_URL=http://login:3001/ui/v2/login + - CYPRESS_CORE_MOCK_STUBS_URL=http://core-mock:22220/v1/stubs + depends_on: + login: + condition: service_started + core-mock: + condition: service_started diff --git a/login/apps/login-test-integration/fixtures/example.json b/login/apps/login-test-integration/fixtures/example.json new file mode 100644 index 0000000000..02e4254378 --- /dev/null +++ b/login/apps/login-test-integration/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/login/apps/login-test-integration/integration/invite.cy.ts b/login/apps/login-test-integration/integration/invite.cy.ts new file mode 100644 index 0000000000..a68ff96c36 --- /dev/null +++ b/login/apps/login-test-integration/integration/invite.cy.ts @@ -0,0 +1,110 @@ +import { stub } from "../support/e2e"; + +describe("verify invite", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [], // user with no auth methods was invited + }, + }); + + stub("zitadel.user.v2.UserService", "GetUserByID", { + data: { + user: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: false, + }, + }, + }, + }, + }); + + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + + stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + allowUsernamePassword: true, + }, + }, + }); + }); + + it.only("shows authenticators after successful invite verification", () => { + stub("zitadel.user.v2.UserService", "VerifyInviteCode"); + + cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/authenticator/set"); + }); + + it("shows an error if invite code validation failed", () => { + stub("zitadel.user.v2.UserService", "VerifyInviteCode", { + code: 3, + error: "error validating code", + }); + + // TODO: Avoid uncaught exception in application + cy.once("uncaught:exception", () => false); + cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); + cy.contains("Could not verify invite", { timeout: 10_000 }); + }); +}); diff --git a/login/apps/login-test-integration/integration/login.cy.ts b/login/apps/login-test-integration/integration/login.cy.ts new file mode 100644 index 0000000000..917d719cb1 --- /dev/null +++ b/login/apps/login-test-integration/integration/login.cy.ts @@ -0,0 +1,172 @@ +import { stub } from "../support/e2e"; + +describe("login", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + + stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + allowUsernamePassword: true, + }, + }, + }); + }); + describe("password login", () => { + beforeEach(() => { + stub("zitadel.user.v2.UserService", "ListUsers", { + data: { + details: { + totalResult: 1, + }, + result: [ + { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: true, + }, + }, + }, + ], + }, + }); + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [1], // 1 for password authentication + }, + }); + }); + it("should redirect a user with password authentication to /password", () => { + cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/password"); + }); + describe("with passkey prompt", () => { + beforeEach(() => { + stub("zitadel.session.v2.SessionService", "SetSession", { + data: { + details: { + sequence: 859, + changeDate: "2023-07-04T07:58:20.126Z", + resourceOwner: "220516472055706145", + }, + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + }); + // it("should prompt a user to setup passwordless authentication if passkey is allowed in the login settings", () => { + // cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); + // cy.location("pathname", { timeout: 10_000 }).should("eq", "/password"); + // cy.get('input[type="password"]').focus().type("MyStrongPassword!1"); + // cy.get('button[type="submit"]').click(); + // cy.location("pathname", { timeout: 10_000 }).should( + // "eq", + // "/passkey/set", + // ); + // }); + }); + }); + describe("passkey login", () => { + beforeEach(() => { + stub("zitadel.user.v2.UserService", "ListUsers", { + data: { + details: { + totalResult: 1, + }, + result: [ + { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: true, + }, + }, + }, + ], + }, + }); + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [2], // 2 for passwordless authentication + }, + }); + }); + + it("should redirect a user with passwordless authentication to /passkey", () => { + cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/passkey"); + }); + }); +}); diff --git a/login/apps/login-test-integration/integration/register-idp.cy.ts b/login/apps/login-test-integration/integration/register-idp.cy.ts new file mode 100644 index 0000000000..73a0c32e00 --- /dev/null +++ b/login/apps/login-test-integration/integration/register-idp.cy.ts @@ -0,0 +1,21 @@ +import { stub } from "../support/e2e"; + +const IDP_URL = "https://example.com/idp/url"; + +describe("register idps", () => { + beforeEach(() => { + stub("zitadel.user.v2.UserService", "StartIdentityProviderIntent", { + data: { + authUrl: IDP_URL, + }, + }); + }); + + it("should redirect the user to the correct url", () => { + cy.visit("/idp"); + cy.get('button[e2e="google"]').click(); + cy.origin(IDP_URL, { args: IDP_URL }, (url) => { + cy.location("href", { timeout: 10_000 }).should("eq", url); + }); + }); +}); diff --git a/login/apps/login-test-integration/integration/register.cy.ts b/login/apps/login-test-integration/integration/register.cy.ts new file mode 100644 index 0000000000..44c53647c1 --- /dev/null +++ b/login/apps/login-test-integration/integration/register.cy.ts @@ -0,0 +1,73 @@ +import { stub } from "../support/e2e"; + +describe("register", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + allowRegister: true, + allowUsernamePassword: true, + defaultRedirectUri: "", + }, + }, + }); + stub("zitadel.user.v2.UserService", "AddHumanUser", { + data: { + userId: "221394658884845598", + }, + }); + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + }); + + it("should redirect a user who selects passwordless on register to /passkey/set", () => { + cy.visit("/register"); + cy.get('input[data-testid="firstname-text-input"]').focus().type("John"); + cy.get('input[data-testid="lastname-text-input"]').focus().type("Doe"); + cy.get('input[data-testid="email-text-input"]').focus().type("john@zitadel.com"); + cy.get('input[type="checkbox"][value="privacypolicy"]').check(); + cy.get('input[type="checkbox"][value="tos"]').check(); + cy.get('button[type="submit"]').click(); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/passkey/set"); + }); +}); diff --git a/login/apps/login-test-integration/integration/verify.cy.ts b/login/apps/login-test-integration/integration/verify.cy.ts new file mode 100644 index 0000000000..db80cea720 --- /dev/null +++ b/login/apps/login-test-integration/integration/verify.cy.ts @@ -0,0 +1,95 @@ +import { stub } from "../support/e2e"; + +describe("verify email", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [1], // set one method such that we know that the user was not invited + }, + }); + + stub("zitadel.user.v2.UserService", "SendEmailCode"); + + stub("zitadel.user.v2.UserService", "GetUserByID", { + data: { + user: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: false, // email is not verified yet + }, + }, + }, + }, + }); + + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + }); + + it("shows an error if email code validation failed", () => { + stub("zitadel.user.v2.UserService", "VerifyEmail", { + code: 3, + error: "error validating code", + }); + // TODO: Avoid uncaught exception in application + cy.once("uncaught:exception", () => false); + cy.visit("/verify?userId=221394658884845598&code=abc"); + cy.contains("Could not verify email", { timeout: 10_000 }); + }); +}); diff --git a/login/apps/login-test-integration/package.json b/login/apps/login-test-integration/package.json new file mode 100644 index 0000000000..f45c5a3413 --- /dev/null +++ b/login/apps/login-test-integration/package.json @@ -0,0 +1,17 @@ +{ + "name": "login-test-integration", + "private": true, + "scripts": { + "test:integration": "dotenv -e ../login/.env.test pnpm exec cypress", + "test:integration:setup": "cd ../.. && make login_test_integration_dev" + }, + "devDependencies": { + "@types/node": "^22.14.1", + "concurrently": "^9.1.2", + "cypress": "^14.3.2", + "env-cmd": "^10.0.0", + "nodemon": "^3.1.9", + "start-server-and-test": "^2.0.11", + "typescript": "^5.8.3" + } +} diff --git a/login/apps/login-test-integration/support/e2e.ts b/login/apps/login-test-integration/support/e2e.ts new file mode 100644 index 0000000000..58056c973e --- /dev/null +++ b/login/apps/login-test-integration/support/e2e.ts @@ -0,0 +1,29 @@ +const url = Cypress.env("CORE_MOCK_STUBS_URL") || "http://localhost:22220/v1/stubs"; + +function removeStub(service: string, method: string) { + return cy.request({ + url, + method: "DELETE", + qs: { + service, + method, + }, + }); +} + +export function stub(service: string, method: string, out?: any) { + removeStub(service, method); + return cy.request({ + url, + method: "POST", + body: { + stubs: [ + { + service, + method, + out, + }, + ], + }, + }); +} diff --git a/login/apps/login-test-integration/tsconfig.json b/login/apps/login-test-integration/tsconfig.json new file mode 100644 index 0000000000..18edb199ac --- /dev/null +++ b/login/apps/login-test-integration/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "node"] + }, + "include": ["**/*.ts"] +} diff --git a/login/apps/login-test-integration/turbo.json b/login/apps/login-test-integration/turbo.json new file mode 100644 index 0000000000..2e2c7cfb42 --- /dev/null +++ b/login/apps/login-test-integration/turbo.json @@ -0,0 +1,10 @@ +{ + "extends": ["//"], + "tasks": { + "test:integration:setup": { + "interactive": true, + "cache": false, + "persistent": true + } + } +} diff --git a/login/apps/login/.env.test b/login/apps/login/.env.test new file mode 100644 index 0000000000..ee70003348 --- /dev/null +++ b/login/apps/login/.env.test @@ -0,0 +1,5 @@ +NEXT_PUBLIC_BASE_PATH="" +ZITADEL_API_URL=http://localhost:22222 +ZITADEL_SERVICE_USER_TOKEN="yolo" +EMAIL_VERIFICATION=true +DEBUG=true diff --git a/login/apps/login/.eslintrc.cjs b/login/apps/login/.eslintrc.cjs new file mode 100755 index 0000000000..f5383dd47a --- /dev/null +++ b/login/apps/login/.eslintrc.cjs @@ -0,0 +1,12 @@ +module.exports = { + extends: ["next/core-web-vitals"], + ignorePatterns: ["external/**/*.ts"], + rules: { + "@next/next/no-html-link-for-pages": "off", + }, + settings: { + react: { + version: "detect", + }, + }, +}; diff --git a/login/apps/login/.gitignore b/login/apps/login/.gitignore new file mode 100644 index 0000000000..caf3c1ec81 --- /dev/null +++ b/login/apps/login/.gitignore @@ -0,0 +1,3 @@ +custom-config.js +.env*.local +standalone diff --git a/login/apps/login/.prettierignore b/login/apps/login/.prettierignore new file mode 100644 index 0000000000..dbcbbd11d1 --- /dev/null +++ b/login/apps/login/.prettierignore @@ -0,0 +1,2 @@ +.next +/external \ No newline at end of file diff --git a/login/apps/login/constants/csp.js b/login/apps/login/constants/csp.js new file mode 100644 index 0000000000..5cc1e254f3 --- /dev/null +++ b/login/apps/login/constants/csp.js @@ -0,0 +1,2 @@ +export const DEFAULT_CSP = + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com; connect-src 'self'; child-src; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; img-src 'self' https://vercel.com;"; diff --git a/login/apps/login/locales/de.json b/login/apps/login/locales/de.json new file mode 100644 index 0000000000..75897a628e --- /dev/null +++ b/login/apps/login/locales/de.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Zurück" + }, + "accounts": { + "title": "Konten", + "description": "Wählen Sie das Konto aus, das Sie verwenden möchten.", + "addAnother": "Ein weiteres Konto hinzufügen", + "noResults": "Keine Konten gefunden" + }, + "logout": { + "title": "Logout", + "description": "Wählen Sie den Account aus, das Sie entfernen möchten", + "noResults": "Keine Konten gefunden", + "clear": "Session beenden", + "verifiedAt": "Zuletzt aktiv: {time}", + "success": { + "title": "Logout erfolgreich", + "description": "Sie haben sich erfolgreich abgemeldet." + } + }, + "loginname": { + "title": "Willkommen zurück!", + "description": "Geben Sie Ihre Anmeldedaten ein.", + "register": "Neuen Benutzer registrieren", + "submit": "Weiter" + }, + "password": { + "verify": { + "title": "Passwort", + "description": "Geben Sie Ihr Passwort ein.", + "resetPassword": "Passwort zurücksetzen", + "submit": "Weiter" + }, + "set": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "codeSent": "Ein Code wurde an Ihre E-Mail-Adresse gesendet.", + "noCodeReceived": "Keinen Code erhalten?", + "resend": "Erneut senden", + "submit": "Weiter" + }, + "change": { + "title": "Passwort ändern", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "idp": { + "title": "Mit SSO anmelden", + "description": "Wählen Sie einen der folgenden Anbieter, um sich anzumelden", + "orSignInWith": "oder melden Sie sich an mit", + "signInWithApple": "Mit Apple anmelden", + "signInWithGoogle": "Mit Google anmelden", + "signInWithAzureAD": "Mit AzureAD anmelden", + "signInWithGithub": "Mit GitHub anmelden", + "signInWithGitlab": "Mit GitLab anmelden", + "loginSuccess": { + "title": "Anmeldung erfolgreich", + "description": "Sie haben sich erfolgreich angemeldet!" + }, + "linkingSuccess": { + "title": "Konto verknüpft", + "description": "Sie haben Ihr Konto erfolgreich verknüpft!" + }, + "registerSuccess": { + "title": "Registrierung erfolgreich", + "description": "Sie haben sich erfolgreich registriert!" + }, + "loginError": { + "title": "Anmeldung fehlgeschlagen", + "description": "Beim Anmelden ist ein Fehler aufgetreten." + }, + "linkingError": { + "title": "Konto-Verknüpfung fehlgeschlagen", + "description": "Beim Verknüpfen Ihres Kontos ist ein Fehler aufgetreten." + }, + "completeRegister": { + "title": "Registrierung abschließen", + "description": "Bitte vervollständige die Registrierung, um dein Konto zu erstellen." + } + }, + "ldap": { + "title": "LDAP Login", + "description": "Geben Sie Ihre LDAP-Anmeldedaten ein.", + "username": "Benutzername", + "password": "Passwort", + "submit": "Weiter" + }, + "mfa": { + "verify": { + "title": "Bestätigen Sie Ihre Identität", + "description": "Wählen Sie einen der folgenden Faktoren.", + "noResults": "Keine zweiten Faktoren verfügbar, um sie einzurichten." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Wählen Sie einen der folgenden zweiten Faktoren.", + "skip": "Überspringen" + } + }, + "otp": { + "verify": { + "title": "2-Faktor bestätigen", + "totpDescription": "Geben Sie den Code aus Ihrer Authentifizierungs-App ein.", + "smsDescription": "Geben Sie den Code ein, den Sie per SMS erhalten haben.", + "emailDescription": "Geben Sie den Code ein, den Sie per E-Mail erhalten haben.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "submit": "Weiter" + }, + "set": { + "title": "2-Faktor einrichten", + "totpDescription": "Scannen Sie den QR-Code mit Ihrer Authentifizierungs-App.", + "smsDescription": "Geben Sie Ihre Telefonnummer ein, um einen Code per SMS zu erhalten.", + "emailDescription": "Geben Sie Ihre E-Mail-Adresse ein, um einen Code per E-Mail zu erhalten.", + "totpRegisterDescription": "Scannen Sie den QR-Code oder navigieren Sie manuell zur URL.", + "submit": "Weiter" + } + }, + "passkey": { + "verify": { + "title": "Mit einem Passkey authentifizieren", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "usePassword": "Passwort verwenden", + "submit": "Weiter" + }, + "set": { + "title": "Passkey einrichten", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "info": { + "description": "Ein Passkey ist eine Authentifizierungsmethode auf einem Gerät wie Ihr Fingerabdruck, Apple FaceID oder ähnliches.", + "link": "Passwortlose Authentifizierung" + }, + "skip": "Überspringen", + "submit": "Weiter" + } + }, + "u2f": { + "verify": { + "title": "2-Faktor bestätigen", + "description": "Bestätigen Sie Ihr Konto mit Ihrem Gerät." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Richten Sie ein Gerät als zweiten Faktor ein.", + "submit": "Weiter" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registrierung deaktiviert", + "description": "Die Registrierung ist deaktiviert. Bitte wenden Sie sich an den Administrator." + }, + "missingdata": { + "title": "Registrierung fehlgeschlagen", + "description": "Einige Daten fehlen. Bitte überprüfen Sie Ihre Eingaben." + }, + "title": "Registrieren", + "description": "Erstellen Sie Ihr ZITADEL-Konto.", + "noMethodAvailableWarning": "Keine Authentifizierungsmethode verfügbar. Bitte wenden Sie sich an den Administrator.", + "selectMethod": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten", + "agreeTo": "Um sich zu registrieren, müssen Sie den Nutzungsbedingungen zustimmen", + "termsOfService": "Nutzungsbedingungen", + "privacyPolicy": "Datenschutzrichtlinie", + "submit": "Weiter", + "orUseIDP": "oder verwenden Sie einen Identitätsanbieter", + "password": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "invite": { + "title": "Benutzer einladen", + "description": "Geben Sie die E-Mail-Adresse des Benutzers ein, den Sie einladen möchten.", + "info": "Der Benutzer erhält eine E-Mail mit einem Link, um sich zu registrieren.", + "notAllowed": "Sie haben keine Berechtigung, Benutzer einzuladen.", + "submit": "Einladen", + "success": { + "title": "Einladung erfolgreich", + "description": "Der Benutzer wurde erfolgreich eingeladen.", + "verified": "Der Benutzer wurde eingeladen und hat seine E-Mail bereits verifiziert.", + "notVerifiedYet": "Der Benutzer wurde eingeladen. Er erhält eine E-Mail mit weiteren Anweisungen.", + "submit": "Weiteren Benutzer einladen" + } + }, + "signedin": { + "title": "Willkommen {user}!", + "description": "Sie sind angemeldet.", + "continue": "Weiter", + "error": { + "title": "Fehler", + "description": "Ein Fehler ist aufgetreten." + } + }, + "verify": { + "userIdMissing": "Keine Benutzer-ID angegeben!", + "successTitle": "Benutzer verifiziert", + "successDescription": "Der Benutzer wurde erfolgreich verifiziert.", + "setupAuthenticator": "Authentifikator einrichten", + "verify": { + "title": "Benutzer verifizieren", + "description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "codeSent": "Ein Code wurde gerade an Ihre E-Mail-Adresse gesendet.", + "submit": "Weiter" + } + }, + "authenticator": { + "title": "Authentifizierungsmethode auswählen", + "description": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten.", + "noMethodsAvailable": "Keine Authentifizierungsmethoden verfügbar", + "allSetup": "Sie haben bereits einen Authentifikator eingerichtet!", + "linkWithIDP": "oder verknüpfe mit einem Identitätsanbieter" + }, + "device": { + "usercode": { + "title": "Gerätecode", + "description": "Geben Sie den Code ein.", + "submit": "Weiter" + }, + "request": { + "title": "{appName} möchte eine Verbindung herstellen:", + "disclaimer": "{appName} hat Zugriff auf:", + "description": "Durch Klicken auf Zulassen erlauben Sie {appName} und Zitadel, Ihre Informationen gemäß ihren jeweiligen Nutzungsbedingungen und Datenschutzrichtlinien zu verwenden. Sie können diesen Zugriff jederzeit widerrufen.", + "submit": "Zulassen", + "deny": "Ablehnen" + }, + "scope": { + "openid": "Überprüfen Ihrer Identität.", + "email": "Zugriff auf Ihre E-Mail-Adresse.", + "profile": "Zugriff auf Ihre vollständigen Profilinformationen.", + "offline_access": "Erlauben Sie den Offline-Zugriff auf Ihr Konto." + } + }, + "error": { + "noUserCode": "Kein Benutzercode angegeben!", + "noDeviceRequest": " Es wurde keine Geräteanforderung gefunden. Bitte überprüfen Sie die URL.", + "unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.", + "sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", + "failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.", + "tryagain": "Erneut versuchen" + } +} diff --git a/login/apps/login/locales/en.json b/login/apps/login/locales/en.json new file mode 100644 index 0000000000..9f95403063 --- /dev/null +++ b/login/apps/login/locales/en.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Back" + }, + "accounts": { + "title": "Accounts", + "description": "Select the account you want to use.", + "addAnother": "Add another account", + "noResults": "No accounts found" + }, + "logout": { + "title": "Logout", + "description": "Click an account to end the session", + "noResults": "No accounts found", + "clear": "End Session", + "verifiedAt": "Last active: {time}", + "success": { + "title": "Logout successful", + "description": "You have successfully logged out." + } + }, + "loginname": { + "title": "Welcome back!", + "description": "Enter your login data.", + "register": "Register new user", + "submit": "Continue" + }, + "password": { + "verify": { + "title": "Password", + "description": "Enter your password.", + "resetPassword": "Reset Password", + "submit": "Continue" + }, + "set": { + "title": "Set Password", + "description": "Set the password for your account", + "codeSent": "A code has been sent to your email address.", + "noCodeReceived": "Didn't receive a code?", + "resend": "Resend code", + "submit": "Continue" + }, + "change": { + "title": "Change Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "idp": { + "title": "Sign in with SSO", + "description": "Select one of the following providers to sign in", + "orSignInWith": "or sign in with", + "signInWithApple": "Sign in with Apple", + "signInWithGoogle": "Sign in with Google", + "signInWithAzureAD": "Sign in with AzureAD", + "signInWithGithub": "Sign in with GitHub", + "signInWithGitlab": "Sign in with GitLab", + "loginSuccess": { + "title": "Login successful", + "description": "You have successfully been loggedIn!" + }, + "linkingSuccess": { + "title": "Account linked", + "description": "You have successfully linked your account!" + }, + "registerSuccess": { + "title": "Registration successful", + "description": "You have successfully registered!" + }, + "loginError": { + "title": "Login failed", + "description": "An error occurred while trying to login." + }, + "linkingError": { + "title": "Account linking failed", + "description": "An error occurred while trying to link your account." + }, + "completeRegister": { + "title": "Complete your data", + "description": "You need to complete your registration by providing your email address and name." + } + }, + "ldap": { + "title": "LDAP Login", + "description": "Enter your LDAP credentials.", + "username": "Username", + "password": "Password", + "submit": "Continue" + }, + "mfa": { + "verify": { + "title": "Verify your identity", + "description": "Choose one of the following factors.", + "noResults": "No second factors available to setup." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Choose one of the following second factors.", + "skip": "Skip" + } + }, + "otp": { + "verify": { + "title": "Verify 2-Factor", + "totpDescription": "Enter the code from your authenticator app.", + "smsDescription": "Enter the code you received via SMS.", + "emailDescription": "Enter the code you received via email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "submit": "Continue" + }, + "set": { + "title": "Set up 2-Factor", + "totpDescription": "Scan the QR code with your authenticator app.", + "smsDescription": "Enter your phone number to receive a code via SMS.", + "emailDescription": "Enter your email address to receive a code via email.", + "totpRegisterDescription": "Scan the QR Code or navigate to the URL manually.", + "submit": "Continue" + } + }, + "passkey": { + "verify": { + "title": "Authenticate with a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "usePassword": "Use password", + "submit": "Continue" + }, + "set": { + "title": "Setup a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "info": { + "description": "A passkey is an authentication method on a device like your fingerprint, Apple FaceID or similar. ", + "link": "Passwordless Authentication" + }, + "skip": "Skip", + "submit": "Continue" + } + }, + "u2f": { + "verify": { + "title": "Verify 2-Factor", + "description": "Verify your account with your device." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Set up a device as a second factor.", + "submit": "Continue" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "The registration is disabled. Please contact your administrator." + }, + "missingdata": { + "title": "Missing data", + "description": "Provide email, first and last name to register." + }, + "title": "Register", + "description": "Create your ZITADEL account.", + "noMethodAvailableWarning": "No authentication method available. Please contact your administrator.", + "selectMethod": "Select the method you would like to authenticate", + "agreeTo": "To register you must agree to the terms and conditions", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "submit": "Continue", + "orUseIDP": "or use an Identity Provider", + "password": { + "title": "Set Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "invite": { + "title": "Invite User", + "description": "Provide the email address and the name of the user you want to invite.", + "info": "The user will receive an email with further instructions.", + "notAllowed": "Your settings do not allow you to invite users.", + "submit": "Continue", + "success": { + "title": "User invited", + "description": "The email has successfully been sent.", + "verified": "The user has been invited and has already verified his email.", + "notVerifiedYet": "The user has been invited. They will receive an email with further instructions.", + "submit": "Invite another user" + } + }, + "signedin": { + "title": "Welcome {user}!", + "description": "You are signed in.", + "continue": "Continue", + "error": { + "title": "Error", + "description": "An error occurred while trying to sign in." + } + }, + "verify": { + "userIdMissing": "No userId provided!", + "successTitle": "User verified", + "successDescription": "The user has been verified successfully.", + "setupAuthenticator": "Setup authenticator", + "verify": { + "title": "Verify user", + "description": "Enter the Code provided in the verification email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "codeSent": "A code has just been sent to your email address.", + "submit": "Continue" + } + }, + "authenticator": { + "title": "Choose authentication method", + "description": "Select the method you would like to authenticate", + "noMethodsAvailable": "No authentication methods available", + "allSetup": "You have already setup an authenticator!", + "linkWithIDP": "or link with an Identity Provider" + }, + "device": { + "usercode": { + "title": "Device code", + "description": "Enter the code displayed on your app or device.", + "submit": "Continue" + }, + "request": { + "title": "{appName} would like to connect", + "description": "{appName} will have access to:", + "disclaimer": "By clicking Allow, you allow {appName} and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", + "submit": "Allow", + "deny": "Deny" + }, + "scope": { + "openid": "Verify your identity.", + "email": "View your email address.", + "profile": "View your full profile information.", + "offline_access": "Allow offline access to your account." + } + }, + "error": { + "noUserCode": "No user code provided!", + "noDeviceRequest": "No device request found.", + "unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.", + "sessionExpired": "Your current session has expired. Please login again.", + "failedLoading": "Failed to load data. Please try again.", + "tryagain": "Try Again" + } +} diff --git a/login/apps/login/locales/es.json b/login/apps/login/locales/es.json new file mode 100644 index 0000000000..fe88bb94c6 --- /dev/null +++ b/login/apps/login/locales/es.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Atrás" + }, + "accounts": { + "title": "Cuentas", + "description": "Selecciona la cuenta que deseas usar.", + "addAnother": "Agregar otra cuenta", + "noResults": "No se encontraron cuentas" + }, + "logout": { + "title": "Cerrar sesión", + "description": "Selecciona la cuenta que deseas eliminar", + "noResults": "No se encontraron cuentas", + "clear": "Eliminar sesión", + "verifiedAt": "Última actividad: {time}", + "success": { + "title": "Cierre de sesión exitoso", + "description": "Has cerrado sesión correctamente." + } + }, + "loginname": { + "title": "¡Bienvenido de nuevo!", + "description": "Introduce tus datos de acceso.", + "register": "Registrar nuevo usuario", + "submit": "Continuar" + }, + "password": { + "verify": { + "title": "Contraseña", + "description": "Introduce tu contraseña.", + "resetPassword": "Restablecer contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "codeSent": "Se ha enviado un código a su correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resend": "Reenviar código", + "submit": "Continuar" + }, + "change": { + "title": "Cambiar Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "idp": { + "title": "Iniciar sesión con SSO", + "description": "Selecciona uno de los siguientes proveedores para iniciar sesión", + "orSignInWith": "o iniciar sesión con", + "signInWithApple": "Iniciar sesión con Apple", + "signInWithGoogle": "Iniciar sesión con Google", + "signInWithAzureAD": "Iniciar sesión con AzureAD", + "signInWithGithub": "Iniciar sesión con GitHub", + "signInWithGitlab": "Iniciar sesión con GitLab", + "loginSuccess": { + "title": "Inicio de sesión exitoso", + "description": "¡Has iniciado sesión con éxito!" + }, + "linkingSuccess": { + "title": "Cuenta vinculada", + "description": "¡Has vinculado tu cuenta con éxito!" + }, + "registerSuccess": { + "title": "Registro exitoso", + "description": "¡Te has registrado con éxito!" + }, + "loginError": { + "title": "Error de inicio de sesión", + "description": "Ocurrió un error al intentar iniciar sesión." + }, + "linkingError": { + "title": "Error al vincular la cuenta", + "description": "Ocurrió un error al intentar vincular tu cuenta." + }, + "completeRegister": { + "title": "Completar registro", + "description": "Para completar el registro, debes establecer una contraseña." + } + }, + "ldap": { + "title": "Iniciar sesión con LDAP", + "description": "Introduce tus credenciales LDAP.", + "username": "Nombre de usuario", + "password": "Contraseña", + "submit": "Continuar" + }, + "mfa": { + "verify": { + "title": "Verifica tu identidad", + "description": "Elige uno de los siguientes factores.", + "noResults": "No hay factores secundarios disponibles para configurar." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Elige uno de los siguientes factores secundarios.", + "skip": "Omitir" + } + }, + "otp": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "totpDescription": "Introduce el código de tu aplicación de autenticación.", + "smsDescription": "Introduce el código que recibiste por SMS.", + "emailDescription": "Introduce el código que recibiste por correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "submit": "Continuar" + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "totpDescription": "Escanea el código QR con tu aplicación de autenticación.", + "smsDescription": "Introduce tu número de teléfono para recibir un código por SMS.", + "emailDescription": "Introduce tu dirección de correo electrónico para recibir un código por correo electrónico.", + "totpRegisterDescription": "Escanea el código QR o navega manualmente a la URL.", + "submit": "Continuar" + } + }, + "passkey": { + "verify": { + "title": "Autenticar con una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "usePassword": "Usar contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Configurar una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "info": { + "description": "Una clave de acceso es un método de autenticación en un dispositivo como tu huella digital, Apple FaceID o similar.", + "link": "Autenticación sin contraseña" + }, + "skip": "Omitir", + "submit": "Continuar" + } + }, + "u2f": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "description": "Verifica tu cuenta con tu dispositivo." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Configura un dispositivo como segundo factor.", + "submit": "Continuar" + } + }, + "register": { + "methods": { + "passkey": "Clave de acceso", + "password": "Contraseña" + }, + "disabled": { + "title": "Registro deshabilitado", + "description": "Registrarse está deshabilitado en este momento." + }, + "missingdata": { + "title": "Datos faltantes", + "description": "No se proporcionaron datos suficientes para el registro." + }, + "title": "Registrarse", + "description": "Crea tu cuenta ZITADEL.", + "noMethodAvailableWarning": "No hay métodos de autenticación disponibles. Por favor, contacta a tu administrador.", + "selectMethod": "Selecciona el método con el que deseas autenticarte", + "agreeTo": "Para registrarte debes aceptar los términos y condiciones", + "termsOfService": "Términos de Servicio", + "privacyPolicy": "Política de Privacidad", + "submit": "Continuar", + "orUseIDP": "o usa un Proveedor de Identidad", + "password": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "invite": { + "title": "Invitar usuario", + "description": "Introduce el correo electrónico del usuario que deseas invitar.", + "info": "El usuario recibirá un correo electrónico con un enlace para completar el registro.", + "notAllowed": "No tienes permiso para invitar usuarios.", + "submit": "Invitar usuario", + "success": { + "title": "¡Usuario invitado!", + "description": "El usuario ha sido invitado.", + "verified": "El usuario ha sido invitado y ya ha verificado su correo electrónico.", + "notVerifiedYet": "El usuario ha sido invitado. Recibirá un correo electrónico con más instrucciones.", + "submit": "Invitar a otro usuario" + } + }, + "signedin": { + "title": "¡Bienvenido {user}!", + "description": "Has iniciado sesión.", + "continue": "Continuar", + "error": { + "title": "Error", + "description": "Ocurrió un error al iniciar sesión." + } + }, + "verify": { + "userIdMissing": "¡No se proporcionó userId!", + "successTitle": "Usuario verificado", + "successDescription": "El usuario ha sido verificado con éxito.", + "setupAuthenticator": "Configurar autenticador", + "verify": { + "title": "Verificar usuario", + "description": "Introduce el código proporcionado en el correo electrónico de verificación.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "codeSent": "Se ha enviado un código a tu dirección de correo electrónico.", + "submit": "Continuar" + } + }, + "authenticator": { + "title": "Seleccionar método de autenticación", + "description": "Selecciona el método con el que deseas autenticarte", + "noMethodsAvailable": "No hay métodos de autenticación disponibles", + "allSetup": "¡Ya has configurado un autenticador!", + "linkWithIDP": "o vincúlalo con un proveedor de identidad" + }, + "device": { + "usercode": { + "title": "Código del dispositivo", + "description": "Introduce el código.", + "submit": "Continuar" + }, + "request": { + "title": "{appName} desea conectarse:", + "description": "{appName} tendrá acceso a:", + "disclaimer": "Al hacer clic en Permitir, autorizas a {appName} y a Zitadel a usar tu información de acuerdo con sus respectivos términos de servicio y políticas de privacidad. Puedes revocar este acceso en cualquier momento.", + "submit": "Permitir", + "deny": "Denegar" + }, + "scope": { + "openid": "Verifica tu identidad.", + "email": "Accede a tu dirección de correo electrónico.", + "profile": "Accede a la información completa de tu perfil.", + "offline_access": "Permitir acceso sin conexión a tu cuenta." + } + }, + "error": { + "noUserCode": "¡No se proporcionó código de usuario!", + "noDeviceRequest": "No se encontró ninguna solicitud de dispositivo.", + "unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.", + "sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.", + "failedLoading": "No se pudieron cargar los datos. Por favor, inténtalo de nuevo.", + "tryagain": "Intentar de nuevo" + } +} diff --git a/login/apps/login/locales/it.json b/login/apps/login/locales/it.json new file mode 100644 index 0000000000..1229a1a4c0 --- /dev/null +++ b/login/apps/login/locales/it.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Indietro" + }, + "accounts": { + "title": "Account", + "description": "Seleziona l'account che desideri utilizzare.", + "addAnother": "Aggiungi un altro account", + "noResults": "Nessun account trovato" + }, + "logout": { + "title": "Esci", + "description": "Seleziona l'account che desideri uscire", + "noResults": "Nessun account trovato", + "clear": "Elimina sessione", + "verifiedAt": "Ultima attività: {time}", + "success": { + "title": "Uscita riuscita", + "description": "Hai effettuato l'uscita con successo." + } + }, + "loginname": { + "title": "Bentornato!", + "description": "Inserisci i tuoi dati di accesso.", + "register": "Registrati come nuovo utente", + "submit": "Continua" + }, + "password": { + "verify": { + "title": "Password", + "description": "Inserisci la tua password.", + "resetPassword": "Reimposta Password", + "submit": "Continua" + }, + "set": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "codeSent": "Un codice è stato inviato al tuo indirizzo email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resend": "Invia di nuovo", + "submit": "Continua" + }, + "change": { + "title": "Cambia Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "idp": { + "title": "Accedi con SSO", + "description": "Seleziona uno dei seguenti provider per accedere", + "orSignInWith": "o accedi con", + "signInWithApple": "Accedi con Apple", + "signInWithGoogle": "Accedi con Google", + "signInWithAzureAD": "Accedi con AzureAD", + "signInWithGithub": "Accedi con GitHub", + "signInWithGitlab": "Accedi con GitLab", + "loginSuccess": { + "title": "Accesso riuscito", + "description": "Accesso effettuato con successo!" + }, + "linkingSuccess": { + "title": "Account collegato", + "description": "Hai collegato con successo il tuo account!" + }, + "registerSuccess": { + "title": "Registrazione riuscita", + "description": "Registrazione effettuata con successo!" + }, + "loginError": { + "title": "Accesso fallito", + "description": "Si è verificato un errore durante il tentativo di accesso." + }, + "linkingError": { + "title": "Collegamento account fallito", + "description": "Si è verificato un errore durante il tentativo di collegare il tuo account." + }, + "completeRegister": { + "title": "Completa la registrazione", + "description": "Completa la registrazione del tuo account." + } + }, + "ldap": { + "title": "Accedi con LDAP", + "description": "Inserisci le tue credenziali LDAP.", + "username": "Nome utente", + "password": "Password", + "submit": "Continua" + }, + "mfa": { + "verify": { + "title": "Verifica la tua identità", + "description": "Scegli uno dei seguenti fattori.", + "noResults": "Nessun secondo fattore disponibile per la configurazione." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Scegli uno dei seguenti secondi fattori.", + "skip": "Salta" + } + }, + "otp": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "totpDescription": "Inserisci il codice dalla tua app di autenticazione.", + "smsDescription": "Inserisci il codice ricevuto via SMS.", + "emailDescription": "Inserisci il codice ricevuto via email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "submit": "Continua" + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "totpDescription": "Scansiona il codice QR con la tua app di autenticazione.", + "smsDescription": "Inserisci il tuo numero di telefono per ricevere un codice via SMS.", + "emailDescription": "Inserisci il tuo indirizzo email per ricevere un codice via email.", + "totpRegisterDescription": "Scansiona il codice QR o naviga manualmente all'URL.", + "submit": "Continua" + } + }, + "passkey": { + "verify": { + "title": "Autenticati con una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "usePassword": "Usa password", + "submit": "Continua" + }, + "set": { + "title": "Configura una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "info": { + "description": "Una passkey è un metodo di autenticazione su un dispositivo come la tua impronta digitale, Apple FaceID o simili.", + "link": "Autenticazione senza password" + }, + "skip": "Salta", + "submit": "Continua" + } + }, + "u2f": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "description": "Verifica il tuo account con il tuo dispositivo." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Configura un dispositivo come secondo fattore.", + "submit": "Continua" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "Registrazione disabilitata. Contatta l'amministratore di sistema per assistenza." + }, + "missingdata": { + "title": "Registrazione", + "description": "Inserisci i tuoi dati per registrarti." + }, + "title": "Registrati", + "description": "Crea il tuo account ZITADEL.", + "noMethodAvailableWarning": "Nessun metodo di autenticazione disponibile. Contatta l'amministratore di sistema per assistenza.", + "selectMethod": "Seleziona il metodo con cui desideri autenticarti", + "agreeTo": "Per registrarti devi accettare i termini e le condizioni", + "termsOfService": "Termini di Servizio", + "privacyPolicy": "Informativa sulla Privacy", + "submit": "Continua", + "orUseIDP": "o usa un Identity Provider", + "password": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "invite": { + "title": "Invita Utente", + "description": "Inserisci l'indirizzo email dell'utente che desideri invitare.", + "info": "L'utente riceverà un'email con ulteriori istruzioni.", + "notAllowed": "Non hai i permessi per invitare un utente.", + "submit": "Invita Utente", + "success": { + "title": "Invito inviato", + "description": "L'utente è stato invitato con successo.", + "verified": "L'utente è stato invitato e ha già verificato la sua email.", + "notVerifiedYet": "L'utente è stato invitato. Riceverà un'email con ulteriori istruzioni.", + "submit": "Invita un altro utente" + } + }, + "signedin": { + "title": "Benvenuto {user}!", + "description": "Sei connesso.", + "continue": "Continua", + "error": { + "title": "Errore", + "description": "Si è verificato un errore durante il tentativo di accesso." + } + }, + "verify": { + "userIdMissing": "Nessun userId fornito!", + "successTitle": "Utente verificato", + "successDescription": "L'utente è stato verificato con successo.", + "setupAuthenticator": "Configura autenticatore", + "verify": { + "title": "Verifica utente", + "description": "Inserisci il codice fornito nell'email di verifica.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "codeSent": "Un codice è stato appena inviato al tuo indirizzo email.", + "submit": "Continua" + } + }, + "authenticator": { + "title": "Seleziona metodo di autenticazione", + "description": "Seleziona il metodo con cui desideri autenticarti", + "noMethodsAvailable": "Nessun metodo di autenticazione disponibile", + "allSetup": "Hai già configurato un autenticatore!", + "linkWithIDP": "o collega con un Identity Provider" + }, + "device": { + "usercode": { + "title": "Codice dispositivo", + "description": "Inserisci il codice.", + "submit": "Continua" + }, + "request": { + "title": "{appName} desidera connettersi:", + "description": "{appName} avrà accesso a:", + "disclaimer": "Cliccando su Consenti, autorizzi {appName} e Zitadel a utilizzare le tue informazioni in conformità con i rispettivi termini di servizio e politiche sulla privacy. Puoi revocare questo accesso in qualsiasi momento.", + "submit": "Consenti", + "deny": "Nega" + }, + "scope": { + "openid": "Verifica la tua identità.", + "email": "Accedi al tuo indirizzo email.", + "profile": "Accedi alle informazioni complete del tuo profilo.", + "offline_access": "Consenti l'accesso offline al tuo account." + } + }, + "error": { + "noUserCode": "Nessun codice utente fornito!", + "noDeviceRequest": "Nessuna richiesta di dispositivo trovata.", + "unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.", + "sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.", + "failedLoading": "Impossibile caricare i dati. Riprova.", + "tryagain": "Riprova" + } +} diff --git a/login/apps/login/locales/pl.json b/login/apps/login/locales/pl.json new file mode 100644 index 0000000000..9fea6a19fa --- /dev/null +++ b/login/apps/login/locales/pl.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Powrót" + }, + "accounts": { + "title": "Konta", + "description": "Wybierz konto, którego chcesz użyć.", + "addAnother": "Dodaj kolejne konto", + "noResults": "Nie znaleziono kont" + }, + "logout": { + "title": "Wyloguj się", + "description": "Wybierz konto, które chcesz usunąć", + "noResults": "Nie znaleziono kont", + "clear": "Usuń sesję", + "verifiedAt": "Ostatnia aktywność: {time}", + "success": { + "title": "Wylogowanie udane", + "description": "Pomyślnie się wylogowałeś." + } + }, + "loginname": { + "title": "Witamy ponownie!", + "description": "Wprowadź dane logowania.", + "register": "Zarejestruj nowego użytkownika", + "submit": "Kontynuuj" + }, + "password": { + "verify": { + "title": "Hasło", + "description": "Wprowadź swoje hasło.", + "resetPassword": "Zresetuj hasło", + "submit": "Kontynuuj" + }, + "set": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "codeSent": "Kod został wysłany na twój adres e-mail.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resend": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "change": { + "title": "Zmień hasło", + "description": "Ustaw nowe hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "idp": { + "title": "Zaloguj się za pomocą SSO", + "description": "Wybierz jednego z poniższych dostawców, aby się zalogować", + "orSignInWith": "lub zaloguj się przez", + "signInWithApple": "Zaloguj się przez Apple", + "signInWithGoogle": "Zaloguj się przez Google", + "signInWithAzureAD": "Zaloguj się przez AzureAD", + "signInWithGithub": "Zaloguj się przez GitHub", + "signInWithGitlab": "Zaloguj się przez GitLab", + "loginSuccess": { + "title": "Logowanie udane", + "description": "Zostałeś pomyślnie zalogowany!" + }, + "linkingSuccess": { + "title": "Konto powiązane", + "description": "Pomyślnie powiązałeś swoje konto!" + }, + "registerSuccess": { + "title": "Rejestracja udana", + "description": "Pomyślnie się zarejestrowałeś!" + }, + "loginError": { + "title": "Logowanie nieudane", + "description": "Wystąpił błąd podczas próby logowania." + }, + "linkingError": { + "title": "Powiązanie konta nie powiodło się", + "description": "Wystąpił błąd podczas próby powiązania konta." + }, + "completeRegister": { + "title": "Ukończ rejestrację", + "description": "Ukończ rejestrację swojego konta." + } + }, + "ldap": { + "title": "Zaloguj się przez LDAP", + "description": "Wprowadź swoje dane logowania LDAP.", + "username": "Nazwa użytkownika", + "password": "Hasło", + "submit": "Kontynuuj" + }, + "mfa": { + "verify": { + "title": "Zweryfikuj swoją tożsamość", + "description": "Wybierz jeden z poniższych sposobów weryfikacji.", + "noResults": "Nie znaleziono dostępnych metod uwierzytelniania dwuskładnikowego." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Wybierz jedną z poniższych metod drugiego czynnika.", + "skip": "Pomiń" + } + }, + "otp": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Wprowadź kod z aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź kod otrzymany SMS-em.", + "emailDescription": "Wprowadź kod otrzymany e-mailem.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Zeskanuj kod QR za pomocą aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź swój numer telefonu, aby otrzymać kod SMS-em.", + "emailDescription": "Wprowadź swój adres e-mail, aby otrzymać kod e-mailem.", + "totpRegisterDescription": "Zeskanuj kod QR lub otwórz adres URL ręcznie.", + "submit": "Kontynuuj" + } + }, + "passkey": { + "verify": { + "title": "Uwierzytelnij się za pomocą klucza dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "usePassword": "Użyj hasła", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj klucz dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "info": { + "description": "Klucz dostępu to metoda uwierzytelniania na urządzeniu, wykorzystująca np. odcisk palca, Apple FaceID lub podobne rozwiązania.", + "link": "Uwierzytelnianie bez hasła" + }, + "skip": "Pomiń", + "submit": "Kontynuuj" + } + }, + "u2f": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "description": "Zweryfikuj swoje konto za pomocą urządzenia." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Skonfiguruj urządzenie jako dodatkowy czynnik uwierzytelniania.", + "submit": "Kontynuuj" + } + }, + "register": { + "methods": { + "passkey": "Klucz dostępu", + "password": "Hasło" + }, + "disabled": { + "title": "Rejestracja wyłączona", + "description": "Rejestracja jest wyłączona. Skontaktuj się z administratorem." + }, + "missingdata": { + "title": "Brak danych", + "description": "Podaj e-mail, imię i nazwisko, aby się zarejestrować." + }, + "title": "Rejestracja", + "description": "Utwórz konto ZITADEL.", + "noMethodAvailableWarning": "Brak dostępnych metod uwierzytelniania. Skontaktuj się z administratorem.", + "selectMethod": "Wybierz metodę uwierzytelniania, której chcesz użyć", + "agreeTo": "Aby się zarejestrować, musisz zaakceptować warunki korzystania", + "termsOfService": "Regulamin", + "privacyPolicy": "Polityka prywatności", + "submit": "Kontynuuj", + "orUseIDP": "lub użyj dostawcy tożsamości", + "password": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "invite": { + "title": "Zaproś użytkownika", + "description": "Podaj adres e-mail oraz imię i nazwisko użytkownika, którego chcesz zaprosić.", + "info": "Użytkownik otrzyma e-mail z dalszymi instrukcjami.", + "notAllowed": "Twoje ustawienia nie pozwalają na zapraszanie użytkowników.", + "submit": "Kontynuuj", + "success": { + "title": "Użytkownik zaproszony", + "description": "E-mail został pomyślnie wysłany.", + "verified": "Użytkownik został zaproszony i już zweryfikował swój e-mail.", + "notVerifiedYet": "Użytkownik został zaproszony. Otrzyma e-mail z dalszymi instrukcjami.", + "submit": "Zaproś kolejnego użytkownika" + } + }, + "signedin": { + "title": "Witaj {user}!", + "description": "Jesteś zalogowany.", + "continue": "Kontynuuj", + "error": { + "title": "Błąd", + "description": "Nie można załadować danych. Sprawdź połączenie z internetem lub spróbuj ponownie później." + } + }, + "verify": { + "userIdMissing": "Nie podano identyfikatora użytkownika!", + "successTitle": "Weryfikacja zakończona", + "successDescription": "Użytkownik został pomyślnie zweryfikowany.", + "setupAuthenticator": "Skonfiguruj uwierzytelnianie", + "verify": { + "title": "Zweryfikuj użytkownika", + "description": "Wprowadź kod z wiadomości weryfikacyjnej.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "codeSent": "Kod został właśnie wysłany na twój adres e-mail.", + "submit": "Kontynuuj" + } + }, + "authenticator": { + "title": "Wybierz metodę uwierzytelniania", + "description": "Wybierz metodę, której chcesz użyć do uwierzytelnienia.", + "noMethodsAvailable": "Brak dostępnych metod uwierzytelniania", + "allSetup": "Już skonfigurowałeś metodę uwierzytelniania!", + "linkWithIDP": "lub połącz z dostawcą tożsamości" + }, + "device": { + "usercode": { + "title": "Kod urządzenia", + "description": "Wprowadź kod.", + "submit": "Kontynuuj" + }, + "request": { + "title": "{appName} chce się połączyć:", + "description": "{appName} będzie miało dostęp do:", + "disclaimer": "Klikając Zezwól, pozwalasz tej aplikacji i Zitadel na korzystanie z Twoich informacji zgodnie z ich odpowiednimi warunkami użytkowania i politykami prywatności. Możesz cofnąć ten dostęp w dowolnym momencie.", + "submit": "Zezwól", + "deny": "Odmów" + }, + "scope": { + "openid": "Zweryfikuj swoją tożsamość.", + "email": "Uzyskaj dostęp do swojego adresu e-mail.", + "profile": "Uzyskaj dostęp do pełnych informacji o swoim profilu.", + "offline_access": "Zezwól na dostęp offline do swojego konta." + } + }, + "error": { + "noUserCode": "Nie podano kodu użytkownika!", + "noDeviceRequest": "Nie znaleziono żądania urządzenia.", + "unknownContext": "Nie udało się pobrać kontekstu użytkownika. Upewnij się, że najpierw wprowadziłeś nazwę użytkownika lub podałeś login jako parametr wyszukiwania.", + "sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.", + "failedLoading": "Nie udało się załadować danych. Spróbuj ponownie.", + "tryagain": "Spróbuj ponownie" + } +} diff --git a/login/apps/login/locales/ru.json b/login/apps/login/locales/ru.json new file mode 100644 index 0000000000..e745f1ae59 --- /dev/null +++ b/login/apps/login/locales/ru.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Назад" + }, + "accounts": { + "title": "Аккаунты", + "description": "Выберите аккаунт, который хотите использовать.", + "addAnother": "Добавить другой аккаунт", + "noResults": "Аккаунты не найдены" + }, + "logout": { + "title": "Выход", + "description": "Выберите аккаунт, который хотите удалить", + "noResults": "Аккаунты не найдены", + "clear": "Удалить сессию", + "verifiedAt": "Последняя активность: {time}", + "success": { + "title": "Выход выполнен успешно", + "description": "Вы успешно вышли из системы." + } + }, + "loginname": { + "title": "С возвращением!", + "description": "Введите свои данные для входа.", + "register": "Зарегистрировать нового пользователя", + "submit": "Продолжить" + }, + "password": { + "verify": { + "title": "Пароль", + "description": "Введите ваш пароль.", + "resetPassword": "Сбросить пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "codeSent": "Код отправлен на ваш адрес электронной почты.", + "noCodeReceived": "Не получили код?", + "resend": "Отправить код повторно", + "submit": "Продолжить" + }, + "change": { + "title": "Изменить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "idp": { + "title": "Войти через SSO", + "description": "Выберите одного из провайдеров для входа", + "orSignInWith": "или войти через", + "signInWithApple": "Войти через Apple", + "signInWithGoogle": "Войти через Google", + "signInWithAzureAD": "Войти через AzureAD", + "signInWithGithub": "Войти через GitHub", + "signInWithGitlab": "Войти через GitLab", + "loginSuccess": { + "title": "Вход выполнен успешно", + "description": "Вы успешно вошли в систему!" + }, + "linkingSuccess": { + "title": "Аккаунт привязан", + "description": "Аккаунт успешно привязан!" + }, + "registerSuccess": { + "title": "Регистрация завершена", + "description": "Вы успешно зарегистрировались!" + }, + "loginError": { + "title": "Ошибка входа", + "description": "Произошла ошибка при попытке входа." + }, + "linkingError": { + "title": "Ошибка привязки аккаунта", + "description": "Произошла ошибка при попытке привязать аккаунт." + }, + "completeRegister": { + "title": "Завершите регистрацию", + "description": "Завершите регистрацию вашего аккаунта." + } + }, + "ldap": { + "title": "Войти через LDAP", + "description": "Введите ваши учетные данные LDAP.", + "username": "Имя пользователя", + "password": "Пароль", + "submit": "Продолжить" + }, + "mfa": { + "verify": { + "title": "Подтвердите вашу личность", + "description": "Выберите один из следующих факторов.", + "noResults": "Нет доступных методов двухфакторной аутентификации" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Выберите один из следующих методов.", + "skip": "Пропустить" + } + }, + "otp": { + "verify": { + "title": "Подтверждение 2FA", + "totpDescription": "Введите код из приложения-аутентификатора.", + "smsDescription": "Введите код, полученный по SMS.", + "emailDescription": "Введите код, полученный по email.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "totpDescription": "Отсканируйте QR-код в приложении-аутентификаторе.", + "smsDescription": "Введите номер телефона для получения кода по SMS.", + "emailDescription": "Введите email для получения кода.", + "totpRegisterDescription": "Отсканируйте QR-код или перейдите по ссылке вручную.", + "submit": "Продолжить" + } + }, + "passkey": { + "verify": { + "title": "Аутентификация с помощью пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "usePassword": "Использовать пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "info": { + "description": "Пасскей — метод аутентификации через устройство (отпечаток пальца, Apple FaceID и аналоги).", + "link": "Аутентификация без пароля" + }, + "skip": "Пропустить", + "submit": "Продолжить" + } + }, + "u2f": { + "verify": { + "title": "Подтверждение 2FA", + "description": "Подтвердите аккаунт с помощью устройства." + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Настройте устройство как второй фактор.", + "submit": "Продолжить" + } + }, + "register": { + "methods": { + "passkey": "Пасскей", + "password": "Пароль" + }, + "disabled": { + "title": "Регистрация отключена", + "description": "Регистрация недоступна. Обратитесь к администратору." + }, + "missingdata": { + "title": "Недостаточно данных", + "description": "Укажите email, имя и фамилию для регистрации." + }, + "title": "Регистрация", + "description": "Создайте свой аккаунт ZITADEL.", + "noMethodAvailableWarning": "Нет доступных методов аутентификации. Обратитесь к администратору.", + "selectMethod": "Выберите метод аутентификации", + "agreeTo": "Для регистрации необходимо принять условия:", + "termsOfService": "Условия использования", + "privacyPolicy": "Политика конфиденциальности", + "submit": "Продолжить", + "orUseIDP": "или используйте Identity Provider", + "password": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "invite": { + "title": "Пригласить пользователя", + "description": "Укажите email и имя пользователя для приглашения.", + "info": "Пользователь получит email с инструкциями.", + "notAllowed": "Ваши настройки не позволяют приглашать пользователей.", + "submit": "Продолжить", + "success": { + "title": "Пользователь приглашён", + "description": "Письмо успешно отправлено.", + "verified": "Пользователь приглашён и уже подтвердил email.", + "notVerifiedYet": "Пользователь приглашён. Он получит email с инструкциями.", + "submit": "Пригласить другого пользователя" + } + }, + "signedin": { + "title": "Добро пожаловать, {user}!", + "description": "Вы вошли в систему.", + "continue": "Продолжить", + "error": { + "title": "Ошибка", + "description": "Не удалось войти в систему. Проверьте свои данные и попробуйте снова." + } + }, + "verify": { + "userIdMissing": "Не указан userId!", + "successTitle": "Пользователь подтверждён", + "successDescription": "Пользователь успешно подтверждён.", + "setupAuthenticator": "Настроить аутентификатор", + "verify": { + "title": "Подтверждение пользователя", + "description": "Введите код из письма подтверждения.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "codeSent": "Код отправлен на ваш email.", + "submit": "Продолжить" + } + }, + "authenticator": { + "title": "Выбор метода аутентификации", + "description": "Выберите предпочитаемый метод аутентификации", + "noMethodsAvailable": "Нет доступных методов аутентификации", + "allSetup": "Аутентификатор уже настроен!", + "linkWithIDP": "или привязать через Identity Provider" + }, + "device": { + "usercode": { + "title": "Код устройства", + "description": "Введите код.", + "submit": "Продолжить" + }, + "request": { + "title": "{appName} хочет подключиться:", + "description": "{appName} получит доступ к:", + "disclaimer": "Нажимая «Разрешить», вы разрешаете этому приложению и Zitadel использовать вашу информацию в соответствии с их условиями использования и политиками конфиденциальности. Вы можете отозвать этот доступ в любое время.", + "submit": "Разрешить", + "deny": "Запретить" + }, + "scope": { + "openid": "Проверка вашей личности.", + "email": "Доступ к вашему адресу электронной почты.", + "profile": "Доступ к полной информации вашего профиля.", + "offline_access": "Разрешить офлайн-доступ к вашему аккаунту." + } + }, + "error": { + "noUserCode": "Не указан код пользователя!", + "noDeviceRequest": "Не найдена ни одна заявка на устройство.", + "unknownContext": "Не удалось получить контекст пользователя. Укажите имя пользователя или loginName в параметрах поиска.", + "sessionExpired": "Ваша сессия истекла. Войдите снова.", + "failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.", + "tryagain": "Попробовать снова" + } +} diff --git a/login/apps/login/locales/zh.json b/login/apps/login/locales/zh.json new file mode 100644 index 0000000000..5a9cb3a4eb --- /dev/null +++ b/login/apps/login/locales/zh.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "返回" + }, + "accounts": { + "title": "账户", + "description": "选择您想使用的账户。", + "addAnother": "添加另一个账户", + "noResults": "未找到账户" + }, + "logout": { + "title": "注销", + "description": "选择您想注销的账户", + "noResults": "未找到账户", + "clear": "注销会话", + "verifiedAt": "最后活动时间:{time}", + "success": { + "title": "注销成功", + "description": "您已成功注销。" + } + }, + "loginname": { + "title": "欢迎回来!", + "description": "请输入您的登录信息。", + "register": "注册新用户", + "submit": "继续" + }, + "password": { + "verify": { + "title": "密码", + "description": "请输入您的密码。", + "resetPassword": "重置密码", + "submit": "继续" + }, + "set": { + "title": "设置密码", + "description": "为您的账户设置密码", + "codeSent": "验证码已发送到您的邮箱。", + "noCodeReceived": "没有收到验证码?", + "resend": "重发验证码", + "submit": "继续" + }, + "change": { + "title": "更改密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "idp": { + "title": "使用 SSO 登录", + "description": "选择以下提供商中的一个进行登录", + "orSignInWith": "或使用以下方式登录", + "signInWithApple": "用 Apple 登录", + "signInWithGoogle": "用 Google 登录", + "signInWithAzureAD": "用 AzureAD 登录", + "signInWithGithub": "用 GitHub 登录", + "signInWithGitlab": "用 GitLab 登录", + "loginSuccess": { + "title": "登录成功", + "description": "您已成功登录!" + }, + "linkingSuccess": { + "title": "账户已链接", + "description": "您已成功链接您的账户!" + }, + "registerSuccess": { + "title": "注册成功", + "description": "您已成功注册!" + }, + "loginError": { + "title": "登录失败", + "description": "登录时发生错误。" + }, + "linkingError": { + "title": "账户链接失败", + "description": "链接账户时发生错误。" + }, + "completeRegister": { + "title": "完成注册", + "description": "完成您的账户注册。" + } + }, + "ldap": { + "title": "使用 LDAP 登录", + "description": "请输入您的 LDAP 凭据。", + "username": "用户名", + "password": "密码", + "submit": "继续" + }, + "mfa": { + "verify": { + "title": "验证您的身份", + "description": "选择以下的一个因素。", + "noResults": "没有可设置的第二因素。" + }, + "set": { + "title": "设置双因素认证", + "description": "选择以下的一个第二因素。", + "skip": "跳过" + } + }, + "otp": { + "verify": { + "title": "验证双因素", + "totpDescription": "请输入认证应用程序中的验证码。", + "smsDescription": "输入通过短信收到的验证码。", + "emailDescription": "输入通过电子邮件收到的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "submit": "继续" + }, + "set": { + "title": "设置双因素认证", + "totpDescription": "使用认证应用程序扫描二维码。", + "smsDescription": "输入您的电话号码以接收短信验证码。", + "emailDescription": "输入您的电子邮箱地址以接收电子邮件验证码。", + "totpRegisterDescription": "扫描二维码或手动导航到URL。", + "submit": "继续" + } + }, + "passkey": { + "verify": { + "title": "使用密钥认证", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "usePassword": "使用密码", + "submit": "继续" + }, + "set": { + "title": "设置密钥", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "info": { + "description": "密钥是在设备上如指纹、Apple FaceID 或类似的认证方法。", + "link": "无密码认证" + }, + "skip": "跳过", + "submit": "继续" + } + }, + "u2f": { + "verify": { + "title": "验证双因素", + "description": "使用您的设备验证帐户。" + }, + "set": { + "title": "设置双因素认证", + "description": "设置设备为第二因素。", + "submit": "继续" + } + }, + "register": { + "methods": { + "passkey": "密钥", + "password": "密码" + }, + "disabled": { + "title": "注册已禁用", + "description": "您的设置不允许注册新用户。" + }, + "missingdata": { + "title": "缺少数据", + "description": "请提供所有必需的数据。" + }, + "title": "注册", + "description": "创建您的 ZITADEL 账户。", + "noMethodAvailableWarning": "没有可用的认证方法。请联系您的系统管理员。", + "selectMethod": "选择您想使用的认证方法", + "agreeTo": "注册即表示您同意条款和条件", + "termsOfService": "服务条款", + "privacyPolicy": "隐私政策", + "submit": "继续", + "orUseIDP": "或使用身份提供者", + "password": { + "title": "设置密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "invite": { + "title": "邀请用户", + "description": "提供您想邀请的用户的电子邮箱地址和姓名。", + "info": "用户将收到一封包含进一步说明的电子邮件。", + "notAllowed": "您的设置不允许邀请用户。", + "submit": "继续", + "success": { + "title": "用户已邀请", + "description": "邮件已成功发送。", + "verified": "用户已被邀请并已验证其电子邮件。", + "notVerifiedYet": "用户已被邀请。他们将收到一封包含进一步说明的电子邮件。", + "submit": "邀请另一位用户" + } + }, + "signedin": { + "title": "欢迎 {user}!", + "description": "您已登录。", + "continue": "继续", + "error": { + "title": "错误", + "description": "登录时发生错误。" + } + }, + "verify": { + "userIdMissing": "未提供用户 ID!", + "successTitle": "用户已验证", + "successDescription": "用户已成功验证。", + "setupAuthenticator": "设置认证器", + "verify": { + "title": "验证用户", + "description": "输入验证邮件中的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "codeSent": "刚刚发送了一封包含验证码的电子邮件。", + "submit": "继续" + } + }, + "authenticator": { + "title": "选择认证方式", + "description": "选择您想使用的认证方法", + "noMethodsAvailable": "没有可用的认证方法", + "allSetup": "您已经设置好了一个认证器!", + "linkWithIDP": "或将其与身份提供者关联" + }, + "device": { + "usercode": { + "title": "设备代码", + "description": "输入代码。", + "submit": "继续" + }, + "request": { + "title": "{appName} 想要连接:", + "description": "{appName} 将访问:", + "disclaimer": "点击“允许”即表示您允许此应用程序和 Zitadel 根据其各自的服务条款和隐私政策使用您的信息。您可以随时撤销此访问权限。", + "submit": "允许", + "deny": "拒绝" + }, + "scope": { + "openid": "验证您的身份。", + "email": "访问您的电子邮件地址。", + "profile": "访问您的完整个人资料信息。", + "offline_access": "允许离线访问您的账户。" + } + }, + "error": { + "noUserCode": "未提供用户代码!", + "noDeviceRequest": "没有找到设备请求。", + "unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。", + "sessionExpired": "当前会话已过期,请重新登录。", + "failedLoading": "加载数据失败,请再试一次。", + "tryagain": "重试" + } +} diff --git a/login/apps/login/next-env-vars.d.ts b/login/apps/login/next-env-vars.d.ts new file mode 100644 index 0000000000..b7a525858c --- /dev/null +++ b/login/apps/login/next-env-vars.d.ts @@ -0,0 +1,33 @@ +declare namespace NodeJS { + interface ProcessEnv { + // Allow any environment variable that matches the pattern + [key: `${string}_AUDIENCE`]: string; // The system api url + [key: `${string}_SYSTEM_USER_ID`]: string; // The service user id + [key: `${string}_SYSTEM_USER_PRIVATE_KEY`]: string; // The service user private key + + AUDIENCE: string; // The fallback system api url + SYSTEM_USER_ID: string; // The fallback service user id + SYSTEM_USER_PRIVATE_KEY: string; // The fallback service user private key + + /** + * The Zitadel API url + */ + ZITADEL_API_URL: string; + + /** + * The service user token + */ + ZITADEL_SERVICE_USER_TOKEN: string; + + /** + * Optional: wheter a user must have verified email + */ + EMAIL_VERIFICATION: string; + + /** + * Optional: custom request headers to be added to every request + * Split by comma, key value pairs separated by colon + */ + CUSTOM_REQUEST_HEADERS?: string; + } +} diff --git a/login/apps/login/next-env.d.ts b/login/apps/login/next-env.d.ts new file mode 100755 index 0000000000..1b3be0840f --- /dev/null +++ b/login/apps/login/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/login/apps/login/next.config.mjs b/login/apps/login/next.config.mjs new file mode 100755 index 0000000000..b84f11a230 --- /dev/null +++ b/login/apps/login/next.config.mjs @@ -0,0 +1,83 @@ +import createNextIntlPlugin from "next-intl/plugin"; +import { DEFAULT_CSP } from "./constants/csp.js"; + +const withNextIntl = createNextIntlPlugin(); + +/** @type {import('next').NextConfig} */ + +const secureHeaders = [ + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { + key: "Referrer-Policy", + value: "origin-when-cross-origin", + }, + { + key: "X-Frame-Options", + value: "SAMEORIGIN", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-XSS-Protection", + value: "1; mode=block", + }, + { + key: "Content-Security-Policy", + value: `${DEFAULT_CSP} frame-ancestors 'none'`, + }, + { key: "X-Frame-Options", value: "deny" }, +]; + +const imageRemotePatterns = [ + { + protocol: "http", + hostname: "localhost", + port: "8080", + pathname: "/**", + }, + { + protocol: "https", + hostname: "*.zitadel.*", + port: "", + pathname: "/**", + }, +]; + +if (process.env.ZITADEL_API_URL) { + imageRemotePatterns.push({ + protocol: "https", + hostname: process.env.ZITADEL_API_URL?.replace("https://", "") || "", + port: "", + pathname: "/**", + }); +} + +const nextConfig = { + basePath: process.env.NEXT_PUBLIC_BASE_PATH, + output: process.env.NEXT_OUTPUT_MODE || undefined, + reactStrictMode: true, // Recommended for the `pages` directory, default in `app`. + experimental: { + dynamicIO: true, + }, + images: { + remotePatterns: imageRemotePatterns, + }, + eslint: { + ignoreDuringBuilds: true, + }, + async headers() { + return [ + { + source: "/:path*", + headers: secureHeaders, + }, + ]; + }, +}; + +export default withNextIntl(nextConfig); diff --git a/login/apps/login/package.json b/login/apps/login/package.json new file mode 100644 index 0000000000..f498b912c2 --- /dev/null +++ b/login/apps/login/package.json @@ -0,0 +1,75 @@ +{ + "name": "@zitadel/login", + "private": true, + "type": "module", + "scripts": { + "dev": "pnpm next dev --turbopack", + "test:unit": "pnpm vitest", + "test:unit:standalone": "pnpm test:unit", + "test:unit:watch": "pnpm test:unit --watch", + "lint": "pnpm exec next lint && pnpm exec prettier --check .", + "lint:fix": "pnpm exec prettier --write .", + "lint-staged": "lint-staged", + "build": "pnpm exec next build", + "build:login:standalone": "NEXT_PUBLIC_BASE_PATH=/ui/v2/login NEXT_OUTPUT_MODE=standalone pnpm build", + "start": "pnpm build && pnpm exec next start", + "start:built": "pnpm exec next start", + "clean": "pnpm mock:stop && rm -rf .turbo && rm -rf node_modules && rm -rf .next" + }, + "git": { + "pre-commit": "lint-staged" + }, + "lint-staged": { + "*": "prettier --write --ignore-unknown" + }, + "dependencies": { + "@headlessui/react": "^2.1.9", + "@heroicons/react": "2.1.3", + "@tailwindcss/forms": "0.5.7", + "@vercel/analytics": "^1.2.2", + "@zitadel/client": "workspace:*", + "@zitadel/proto": "workspace:*", + "clsx": "1.2.1", + "copy-to-clipboard": "^3.3.3", + "deepmerge": "^4.3.1", + "lucide-react": "0.469.0", + "moment": "^2.29.4", + "next": "15.4.0-canary.86", + "next-intl": "^3.25.1", + "next-themes": "^0.2.1", + "nice-grpc": "2.0.1", + "qrcode.react": "^3.1.0", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-hook-form": "7.39.5", + "tinycolor2": "1.4.2", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@bufbuild/buf": "^1.53.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@types/ms": "2.1.0", + "@types/node": "^22.14.1", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", + "@types/tinycolor2": "1.4.3", + "@types/uuid": "^10.0.0", + "@vercel/git-hooks": "1.0.0", + "@zitadel/eslint-config": "workspace:*", + "@zitadel/prettier-config": "workspace:*", + "@zitadel/tailwind-config": "workspace:*", + "@zitadel/tsconfig": "workspace:*", + "autoprefixer": "10.4.21", + "grpc-tools": "1.13.0", + "jsdom": "^26.1.0", + "lint-staged": "15.5.1", + "make-dir-cli": "4.0.0", + "postcss": "8.5.3", + "prettier-plugin-tailwindcss": "0.6.11", + "sass": "^1.87.0", + "tailwindcss": "3.4.14", + "ts-proto": "^2.7.0", + "typescript": "^5.8.3" + } +} diff --git a/login/apps/login/postcss.config.cjs b/login/apps/login/postcss.config.cjs new file mode 100644 index 0000000000..12a703d900 --- /dev/null +++ b/login/apps/login/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/login/apps/login/prettier.config.mjs b/login/apps/login/prettier.config.mjs new file mode 100644 index 0000000000..6df557c2fd --- /dev/null +++ b/login/apps/login/prettier.config.mjs @@ -0,0 +1 @@ +export { default } from "@zitadel/prettier-config"; diff --git a/login/apps/login/public/checkbox.svg b/login/apps/login/public/checkbox.svg new file mode 100644 index 0000000000..94a3298ae6 --- /dev/null +++ b/login/apps/login/public/checkbox.svg @@ -0,0 +1 @@ + diff --git a/login/apps/login/public/favicon.ico b/login/apps/login/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a901eddc34fa1384048e3f1242c82cd36483ecca GIT binary patch literal 15086 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�bhF5v64Dt2EDBsZ?#{)Beuy)ZdRlbmh{*C=HWGotxL9FwYpRU;(c#Imw(ha>hZ%{>AyxY=nMAnDW5*{U)}gYR={!B zRnjvX;xUo3p)!6L$F83_ev!eM1$#EukpxD@de%39%)#G}WhC@wem~(`GVcgeQ{V4C Zp8@y!i|+AhUN`5hro1PVQx_h1{|6%=p(X$T literal 0 HcmV?d00001 diff --git a/login/apps/login/public/favicon/android-chrome-192x192.png b/login/apps/login/public/favicon/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..f22bd442e631e91a1941f4f9d8b37c1ea4181ed1 GIT binary patch literal 17828 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 Hu0mjftWRU2 literal 0 HcmV?d00001 diff --git a/login/apps/login/public/favicon/android-chrome-512x512.png b/login/apps/login/public/favicon/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..6987ed11b45db3179879b2e0892a7afcdd891249 GIT binary patch literal 137768 zcmXtebx@n%^L22S7MCI|?(R-;FHjtUQ{0P7u@)(mqJ`oVcP|dbCAhm=a1sb4{P=v| zdH=}dnP)PSd(WOd_w3&Nrmd-jhfRqM008h*l;3{<0FYlEkpY`vu(23tuSwtbwRj;B{-=KK^06F*q>R;l-2J|{j`7LU~gsxG>*G5F^@f2*OTIFd?}V+`cb*< zfXB|t`Hq8oC{v||HT5xr`%|3#;UNPg7Me%Ym#7uM+fV${kHSvfj+;vYcO+Kq;rs)F zLv%#n=Hs%Abt(hvHE!%*rup61Lr#?hbf$AoaUwpKe%OSY~>pEC;C$B>WaQZ1MuNu-<@;E>}7;#<7;IeomUD}fSbEW&ot=Gfn_9xN1+3p`OP*cr=()00E^1RN= zKVxu%uUvaxo`}mr2b4^I-a4z$G9HVbj474n<`nuqsQ<=;&(6UllXF<^ZZuA#=05K8 zor9m={g!sBm`nHTgM2n^XF2`$ybF6;FTV3egi8K?zR)mUxH9V8&eYK6>~4*SM0h#E zEy`P<<=oPR3!4>px8^F(9qZ4!!Zb$TX!co@{7XFnVTIKDyex3`+@{hyi(()D@5a(p z{>O!lIZ~=F!QWxW%Z{hVLA(k4xL{9z1Xse}BEk3o>1T7U7SK*_N!2f&lXNQgrFj3< zFj<1#V6ny2L0UR`*~sP#6b4ggs56kE+EN;Z&T#Doc1d%60Y(2*`rUD-aeD4_eV4)M zv3(K3{BGZ(@$@+fPjpo0`!!|?3V?c_CDHxZ;%?l6_3ltLAZIRz`R?$ORF^^=6h z`tNu^Q#8_g|Esequs!zO{8v`IRsw34i zV-W%pRG}^DlF;gu)N%=~hFwdPNm6Zq%@_97b4y}2%Jbtuer}ZgJe9ZS3BGPwhavuT z`hvXIxM+Lu^zuRSL5YqhAgaTX+NKcQ4t*GsDMvkFFAEhiz?^LR>zlPF{yPYb%GJ&6 zheu|xQ|J>W^UKBw3aJ5GFOeZH?taSovyoHSEM?&cGH}Z+T97smZnHBuZwwqfiHIiH zb-E21JfVY{Rxul zCqfkr;xa-5hhhL67P{yykSt?6m;h5SHDj%3!>Z?uh1qt-&i3H-_UFw1k^^@i08kPg zExPS~n!_o3{?kxrkyF#$C3AXglw96R6Hl3ZWH%?q3h7A82_gscRNFE5hc?^F)hU@2 zBryKzo;O(;kh=1(@b(8kWd(nQQBWT7Y@B`q83E5_zN9xvMW{q`sU5r(IpxLE|L1qx z3bAMGH0kVod-=0ZMav5<@qpx(@28?uPhvbKGlCk#TKU7;Fn{on zFDg#swo?z_Uh7-|6_bCzGr=$S% zTr>7>i{JA5l{*2RX{vUKBl)b%TlyOEd!iiCZ&mgdChI#b&KoTek$zpFq&~Kf&4&VK z=6OoFgV|w9t!`*CnC|;7$EhxKhF3+SPpf^{RW&DI<~gkkJ(Eq=k{l(e6l{f9^b83=0j<|`MEo%{IEkhxZCv?1~Bt^=3j>D zz~vCB1awBFUky{GR?-CnW%Bh@tJ0e`D*6?L%Y3$&Oy~MHc;ZL}AS!oDIU6Lk^Q#I7 z=mw)>ljA9;J}=1RdGTayGZ`k%2zP&sem*|*e$mwE|9I2pq87RzW3CJ*Du!68aHqlC zsufeO?8)Q8`3&Hj3E*+n0tLBTLXD0)hw3ERI;68wP6nNIsr(j*RVwp7_i z+~i3saTU5p7BNP2nkJoB$go-p2NC8dvqKXw}2i6b`U z1)mjX2-E^%0v^L58ljKUM=VNh3S<8*Z!8^A&b(Q*VgB~mOW5tXT0o9w*8Tud?;3c3 zglYH9kzgo`At6^K0Ew8X9X*-{sk+$1?kOwhNnddZgm(G9+jF8`dbb?@z`tO*BuSVe zNo_41&O;H8m+{XZP2N|!Swf61!~dd-{q3rHnoT>i9D9i0hlNhFEXzcFd3+^_o3)nb z(v1+A(*cwD1zN|=+m`3T7p|L>{>Sx}7p@0f|13gSH6hxJxuw)jv^dbM+B`P84sveC zfw?XqiUKX34ra z&ak(Vkl~ES4Ge{p^jk*^Cbv(dt8`RuiyT3-m=VYR(r)3oxYlLbmjP}69 z!mt;}LVx0gair<%CiP1VR%5$Cdltf8v3d%-_wiae3{g-^eFow*f_?zx>ZsnH42vT3 z7$6OH>soYK%&g8Wbgy?kxWVgR@XM4Fjz%MG10Q`XppTCX!pXEq*+OoU_}wLr!K+*Ywa2))AzH?08Liksml)L*J7s;)S0tvTDw>0H3wtray%~i zmA>8d4_l`~sjv#`p~$6=pFWZ7gcGT_>78zFAWjJ26%ToDUKVQhzlHvqv49u-oHJ** z<4#(!S6MMn6#v#6pl(ur%K`nRMuIyY@7tzW+O03Ywa8e@Qz)01HsYx%Z2!B8l9*DK zG6bp^BSaw+d+PA@9Tqnc$#nNxIM0v7mOx4HVxa2)hfY0p4uff;JJt{HTvEDMWZl0VRtIOP=f0} z0rX!}3%&a(^`Ywdl}f1LOhep{!nY+#j4p%rh5!6n)G0-}QigEl{gU`WJ`Qw9G8N#~ zH5QkEeF1@@xst*5MX~;Sj#JTZ9=;kO%}rI=02Ymwohx$;lznPcze9=en9YkKa7Yy`+$};eRPKRD8P_G%Q#(rp6E)K4&h{)Fa`$NO1ZiW$@(Ms-%gL zpLEqH+21ue8&5fj@BzSMC-5tO7(U-*gSXS>>Su=)D_@^`h)dqEHP~#^USU4;NZPUO zMp^>m{0Tn!`;@UqKj@ZQr*&Q?-mt?B_s|OTGtb5fYoL;t<8iW{eDxT={H#zd05cfx z#nmu$a9rX9Zrbu$cBkA%IKuHx#@bi1Hat3J?pYE8{v!d+?0+OEQH9AhA%^Bx`72@^ zqI-{b_{`c_S44`?c}?v+f7;~?%wzmAYd6q9NlFw}70G?|xD+Kt=#T-W2==lzOrGIz z>O8~DnA$W__y?k`lnfHeAv0mIr+Z(Hfqb?99#zGbEP8s+c~e04^;S5C8sM9sw7m9t z*Ut!$2S6v@jUSY~>l}~1ntlCGgb>I}n|GTTd4Ox zc#Hmnw?^MBRMr01v&tbsx?@p^-O;$_OR$$qJKO&EE z?5=T9h4DNFL+6+uLZvtYss;txY@G|TpaZ~GovnJjrxdO(3-oCoGAsW zp1mQK>`lG+c*&U}U)tBPj|{7nM7entFJsy@{>%k}LR>%|Jg-KgZMifJi>}k;oa`;* zoG0;}2hhb9_cxu|o+?hpPFGsG;P!{V|1-ju{}l-m<;C3x)mkYl>7^|ooz=#J@!4dy z32FX3{tN%F2I5sU@PI^6I;iIIJ&2@ik8Y}!BsP8Z@bV2_D%B-2*SHCnVp0zC4&=<@ zfo72Hgu?tw;fmsy$O#oxjwATM#El6Epuc{eWSDO0Kil0~v*c{wJVAjWt5X5iKCz%Sk}heeFVe@s!Tyj@(oV`xHP%%@9p-X)5Lu9OS^BGZa5 z*GsrPqoiEC^!5*O3DUH^7%{z_E5vkFKt~E1$;*Xy>{@0N)&>AqBhI+IAffH zW^c!jh($GIuvdTClK^aNEp|n0`A=>~;Sv!^xq(acIMG;k-#nzGCIM4~F;7H$RlW&G z3{0{MF=uzUuyB76oUV` z1X}Oon&7?~ZIkELh1uLRZ_t*GKZkeox@upO1Q?GdCvZ|8K8~$q7t$ORji!2EUga-A z&6E*dId<}Uy8rBkBe7SW&>#xyPd)&W3*WxkrQq3#qW=Z~ruX44x#KG5g05eoYxyF3 zV=^QsWJbOouHJ(i+`fx#iMg9}2>l{}k;{4rWYK(AxTB6@gtnmZ@NbE?k`NHD^zQMr zDA-^qGxI+E!!zaGe%4CXW$5zB?ae_A57HFMGJR}k>{wu&tDqf0PMz+>#^YV3Ag zQMb0=59cU(y|?J&41Zs%YTp$k?{w-&R({yp7YAG|iGrG#CnRqUVm)RN>l`UbBc4S4 z7R^L|k%k&NxEXJ2^(dxmMYLJM@IW>*G-Ww2=tHUxIUSR3)T=i~I^-v> zt;tr9xFvTEytgUm-;|L`F|4bdtB4$t8sYq@cadKY(Sv8 z&=NYGRUiwsDgETzBpt*X73b%3zS#Yx**A){*HHeUy{@%rkviDjI_z|8`LW!)AyoQC z&weCiGL1OGGqr9Cp)Oo1posbrA;C0?7Uo47i5`@9hu2D2nQHSs;BfF(q=zcDC95)q z!d=P@;B~d;v~nB*U?>6GD^c3}#kn+mIEh-L{uf}yp4)$zTt~hXi_j{Dq<(m=h8(o) za2mWQ`TwkD3nncWQCSB{ z7ap0aTms#l<^^QR%QGYq>+k8*XyZN@zM!BNJcCV@8=xl^V}Z9o54ZrHw{ z->eKXZMtJ`G!HsU&Cz1>C|Qxkm<5cp7=+q)rW;wU%KRv-u1LPwF%||Cox+vZtlrzX zWc!~5Zw?NgJ~2v`ZB4W`fErwq<>UR{9s*2nzuJGx^zRIBx#x;+5^()(9k@TZu=hGz zlpvH(-Wb;_W?LX^Z&!)O@ZeiZgbR~(m^0$u6n+Tb1>SDz!D zBqOPeT#hXG+0|`VI{B7QWplCXroC1>(UUp-r5#{v3_}^anO{AfOW%B37BJLh#&M*| zR%Vn;V_mYJ;8XpSE#*6v2fv}mb*H@GRx!6D#}3BXKgM`9a1&d14QWx~ctb4GvO@9> z$mdDnmux&UsC_wO3W+yK7n;7me|DW3Z@rY->^iXgI#WW=G2MOx>lw# zFzq%SidOrrDiev1$%IjjH=2G$5jY~cb6Pa~fh2n}Hs;$>G)2WW+(t@ixRCovy8QV*ykik&?^ zdCi<2d&TNuH+Jc(kZVo}!u`s2oT=LjHKU#P?uMz4bp=exu*686^I^x{fKZ$MwmWUUp$3+ zr-(QISK1^Og**?eCwHj_KYUQpqL#Qfat=LU+@Eefn4SUn6{PD)wm7Hgc9zEq?v?-u zbutVOa`cXI;(MH_O!D(Bl|Oi3Jl1_QnxY%G5mb{)*+Ob5zc2eYCKUwvtvDdL?1!Io zfxeKy-Z{MN+uqtj&+%gyJvMMDF0rorBtAIc{-JH;q!+pwwy89OPhs99qEotH@TGqq5|4CHm(nziwf5~ zUM%}(zhkI{K-sgDN=@2I0~Bmp%@bd)exY4Hs()}NHv&qz*H5Vkgcy+}G=M469fkhV zQWGf(NcuDm{TztCj5=xZe*=h*pr@2OfA9V-cO3oruK9Vwrb9s4!R;HtYR0$~XwrO& z4u8bQxlq8ZFd;vNvtR?$q;-W=op4`OaRAqE?gA?m4fQ^#zV506OR8l!Ctj_B&nCk% z4QJ>2O77!8W@(wTI)aOz@8kYW*BIMM$KvBl`}D=ITLg}^Xi@s7tTw&G1fT0gp*G7m z%uAX>KJX@g&@b1;wwU+xVUtLR_0AI#W+cXnjN`?}C2^0-2y{e|2M)S?WfV^3@V85v z<%z*9@^_XzXXQOC4jJ6vue(n90r9OT2}MiYIfZ*3AlSz zWHZ-YofBw09~9B6gUA2GWA5pKMhuG~=v5`%O9)uYlhd31W-bgsYlLbjQ1p`|9O`(= zJ`bxY9c9CPlM<}0q~fmdensd}zjPrH*1L=p8%@t0nMe{N2Z5QEwexl{^fzZx`- zY`yPHf5O!WM$-s0UqeUNbva`@7sb9%h^{YPc-Z|TVrmGjS`dxYOq2Xvz|hzP6HahK zrT~M7_r#%%#h$N=sO+HQk41RhGs|U0 zB9&)sJakH++yROfqpoph@#+>(=35JSXD1{HtR#f=3+?6>+&+bZSHIWxxEQ~fk(iW_ z=VSA-PFWXt5he5IzdschBznpWnDIu3J{@IZkoz$Bg(>ZMUu`Y-0S2BCqDaVP9|g6m zOsHa!J_Jt^9+KdW7QD^v?cq*hT)6?}z;Jo+&c@>dy2}YP2HZi-k5Om++Iu&ae#JWY zK@0J{SDmM~+3@Tr=Uf4DUfLKxb5Xmt`7Wk=+4^FBG@EMGL5Y>UlE(=fi>{a}uHfEm zPvBr3(2PU=9MN(@yA8$blbZgvT@2^OC;OKfx7 z={ne~wmAXt=aREDU0Jel3$^?Kto$>$4(su6G(Bg&EH-U+N>@9+#?n_bs?Inaw{TB7 zwH|B6JMBMZ8Ut3E1X%#C`H(b#st_uWPuhE(=2e@aNAhN6?N(*SXB8JXc2&va>gtBO z{7YZ|9LFza?QL0;)fOMG>im>{hsjDp(h$6?5qm(r27hC3zo`>HKz^omoddg} z>78r>#_yt6b?;j%BEAcG2p14&XfY}Kq4tvj5(y`3Af{X=NR z?5NbjxBqzD)KQUB`yrw}lrXnXFy`?OCdQV}wb94ecoApwxCtqC15(R5SfIMLzIn- zm=Jo3_o2hA4s1Q2MnC)u_Qgw>JmdMD%{}Nq9NVq0o$gZnS;|s}S|+7gn7pZ}IRUKZ z|9A)>(CsPkx7ab4hGn9A@tmbareKR%M!fL@@;$8Z5oqa*?|4&t|(lqf06bUm1AgbPW3{!=FFk~yUK<-qnd$Kw*HL; zUb_=sVA>6*%f^R!?#JQmA zV$&V|$=+?FqV|!#y!Grwe%0LPn-!pysw>tm1Q3*tiFdhzF&1;_e>{#$5s&QLyt&VT zgrdaXBMV_52wlmsFzy5x&Y0d@m3YkoJ{{ zmBd#2yM(9j!KdHSQ_7jMgwtWIEnf^pPP<|`F)u`)bNoI${1Ib0Y27%!My4EB^NKQ` zOk)m?l?q%{`&yjf*~QlUd;OSz>k;E-0Y_x30rs@vU~tv?@NuY`HOdrLY|Lz%YqIFz z?0}2sFlhxnYk_4b< z7b-q9Tx{lIdpqVLvIfm9i~jwWW2y16dIz|fq8q#1A9qfFH{^_nm<`N`5K2KyDynKe zSeP$#9a3$U6t2DgkDd<41RT(^mX^}HLwxi`a#ogy3gRvsoZO9xk29EDY^ou4k&~(M zG=TvWin7-a{rISjpH9MwmclHRN%uT!e!6hf^Aw7R=ewvjnLPW02}d*hG9s>p?5y&} zk%qCqIyBRZHv4U8)N#nT`nlqFId6u?Pxj(M(s)(okQDha*XEJi9@y*EG}Qn)^{l9` ze87?+RyRdTe$xSY{)oV9BiZwC z`Ki`A{pJrrUEJt^K`t9J+}{vhDi$Ylc0SzcP1binRdLBRecLB&pTsM+Xi7Rx8%eZR zLCsNkf40UYOb0;}UIa#yp2XStEaokc)8%xBhz7;0Y--xFTEb$Q=!5{l8GfN}cer)8 z&11t9wP!y`awLCjkt(kDi)ZWZYec5$_CxBpFgP3f{}oJh!V8R_t;#~x9&_9nJViz| zK2~G?eY0=J0souB+C-bYvRHhjbrHhv%}HGC-?2jK#6}&U@|y6e6>nIwM2}D)`#`^T zsv%XVUq-&V$}Fpw4v2|Hd?vq8N1@rw?=WGv9ks0t#CUT_Jm%!v^8CARoxLFd9tsX- znG4ulO&z6;F5A;B{q8|t!iUd^aM%R zFY?1)uZ0bvnZ5q()e!Ox_Y2=N4$3>e&yjFe&}j6wxZmkODHRJo#rt)}P~bFB!2Wux zp|k60Gn^T~iiFu2RBz{vX=OfOI@Uy6ZwjgVbW}K&cgOt6Y@@U*Nb6bB;#T0z{vXa4 z?8x-a`kojali##NfZ*J$2LA8|QRf(`INLv?ONX;Lwbj zUcL8Go{M{1KfyExz~C~Y0e)kP0QYz5AmGyX60hGLVVahBz%z!_J#MxkZ5u`!?7tE) z4My{wqZj>>obm@*nZSVpp8)G9R1OE^D7B3IY#C7-mmFOhu^|Vn4e(&T>LS0Gse~r$ zq{Z0UDJ$>_Ok(QVgVxtI@`;7{!+LvN>EO4zn@oFC@^p&QQ){!c8fIV8i_S$@+qsAZ z)>;QXa@>slXb9iAt@&dBZ)O08#g~xUE3BcOO*_Npr%$=^HU$#d{ar+t{=;R_^ycix zpq7?UIQJ=wu*QpH)i8zm1gF5ReP1Ruc)J{OhA$~P;*CBmy6rjx?KF(DW(?=;Nv|z|49=RhEf&t5MiW_cm8idrbp|Hs_s+hYTK8X1hL9N|b$ zZ&2VPhXlIo5PQt(?cYm`{-*d1pdWiCX4F!!M2xpBZ%Q7{a!ANvh`5FdwwMv_8PR94 znNIyp&wZ6B9XBLxYC05PjFqnEEjp%zvl%H#arj`)0K3!#gz8q}Recf`vWB zqhH5gkE)U*S!+b7*YX#Z#o>0;5WPE(ULFd&aX9gjw?hqg{U-_CzqXQw&n#+|=@wSn zRrV0p8$u58HpFZ_%6D~S=of#T{3T!b0y*@k#AJG#8x7Kw8Qe4fveT6)JM6i+epAB$ z-+%Tu$E}BLKNf|LPlGb zD~zfHZeZ74m-ySFY3|>7X%yPBK76j>DScR`qFu-N)9cRj3gj^=e8OFm-gFgvIN2wH zszu{SA@Qk})W$!%>Z?=D^;cH4Xv*q>+fON-!IP0GqR8$?Ia2$QD3O!*BDGqi13kGh zMmsdFpE-yn!_GNyhnl@lono6o;eXTbyvv$HGGU&b!Ox43t1pETA72h!VV&c}JIfRB zM6&q@64i>hSahjWJW@e22aY|%>^Rm!9*Kt)BbHz&3_U8pv-&opv4wrr5oiUVYRxsL zCE>G9jY<}BZ0+zQcR8-k(36$VFQ1TUZxQhucM6Wyv(Doh#Gp967RpTjBxVXWCs{CKe%bqicxL zAchton|vN;-NsymDIox;VaizUVVLX zJ&A|Q%VAEti|BBdlLaoOd&#AW$n^?_ri}I4jHX5^d_KZruG<0G*rAn7XswLVWJfjq zGB&!mq)G{t;_lny11<(dzxktnln!~=2Wkh@o)kLQow{fa-+VcQ--dTJxf3yztiNHw zX*67lS{m>R)l=eVuy?y4T!F=zAlzW{^t+t=KZJo z9JA4k0|?iCl8D-Q-|d_u7yy7CCWUA1+ZW06SQ26r5yLSYaytbo0nPL+2efM7Zs*NV zxQNJ+2MbZiHA|w;OG{^y`S|7TwnjWq#Zr6RI?376V>6Vc68ya6yPqOOLfo=AouRC6 z`IonV-YM9iwPXqUMm5w)m_(BjFTh$46JCjF;1?H!MUbjxl1k(7%Ax3Uy=vdUXGqt3v(0&*Z40lzY8lyH}W#2VsW>0);JH@Ou6F) zp7zA7Vl+&&GRN~L(lf~01w^W{8NA2i7nQB0g()s>?aP&~tk=iir!*a_UqBue7;`=!0B^I1jR)X~%AYI4^+ zj1=^ic8PT!@vw@2TnD>2w%rOZ~D{mHamJ5kvu)G{S_4tjIUqYoQ4#1XsFK@2yH?bzU%V+FJ z8xtSOUuzoC5Q6(H#2{>Mx${aiSNAoki$tu1hVUzQ&nGsPpmgKs4hVcPbR!1Ak{FcP z+gK)FlIFYd&peak0_R8i%VDb0AQ??7_ zj)j`Zy4P!ql(0cuA>_NoI&8!)evTp(obd|zUF&&9(k$#BMx0Hg+{!>${cl;1A4T|< ztjiyz4`H+SRDOjX?jUIxVWTUL2+p7+cf>X>kPvhcmuybU0yYm#7aMmX$m6})lK;*a zB-iG==@Do?sXH2kqQsc8Yi)P>HvAY1h>6ZAkBKU!1_ux3o@qAfA89E`7N4V?a=;qS z!Ix7+nPIQ-Pwv_jxTk>fQuu?xnoPq2TiUo%`weG&cz>eem5|7O`j9#Ola%v;)8Gn}c8 zk+A+Dci)`U_N*S{;*Eab_dQg}!oHDx_2cB$g(5BVB=w=4P)?EF!ctRPGn>jrufX*n zzV%fC@2EeZf6x4!m;1u8xmG^F%DmL*#MU zi}>Zno!mpLCq;FeHc~id4EOJ8cVN_Bw~UY0r_Qakr!hkY7!k6fcJOA`LR^(}q*@rF z0dy5Ozp8bc9x~!KtwS^a9}cy*7%PXWu=ZpKnN5*FJu53yJ3tF{N^7y=XeAqV06yGfVR1$2aBCClSeY$#@V`lzuH`C zuP-&XHV-WVY_8s)T_$YG_kFLyg-cgwz0 z&b#$gQuQ1=BJXpMWv>>h(N3RUbB9}r-$xJEpZ3RlBMq1PW1}Jmm1#lR!1YBNUwxBb z?FIaV2u>a{$#j-lQ=Zm^DUO5ZK6#iAj+}=6S%DS887&}2Vxxs0gNkYQby(nke+4}9 zsnnEj0xkI?dtv*DDq?81b2C_F&z4h}>rWhAmP$*wa?V>909iH*ap!bjmZ`g*Q z%cNYmevXO|lZ4W(3`aN~?W_5ctMz+nq@{#Lo5Z7G77jM=H&Cxd)_0UzQ(VVijRa_X zvS0W6Lxo2we2)M29#@)KYNam_)9@O>qm4>^a7+MwFx3|f8?m7vSB1Rm7}l-Iy1L<` zozAKMW7RPw%xA`kA3BqIF~xF&vpceuWHgDh=sP*E{Mpc6z3Wae!@46(P|RqR$;n$J zt7kzi#YjC=L@UgNxzwYl{x0r;w6n_bl&q!u;g{Q#4$ajIIh5L*GZCA48(n!U3ZGo{ zc5dR!2KpLMYYgwt87EV4a#cvfV#P-FueQhHt^%Qoqh5jw9we(Wg*uvSKULov^M(lI zSOW6$cKD?qIC6yl=>`UWY{rWZWB1&&+zuN}HK&A8>D#$h238EF(`Q`jHQ)z$epDJ> zBiQ_LmPb2mMa~`1ikC=**&R(^HN&-l(RMH5g}4dfmTKPkoVZ<*F`fad>vC4AtQ&KB zcVB2}LxXc@MWeaDm7NzP6m3v7%QM4U-5}-4$LPQc7+eJ`Pb|#ykm)}jSt}CucwrW} z*qGAbI-KJL$^<{mKu~JTeO?A)UM{tC{wvYPFTn4aZ-PY?E_~+jL%bO&AI~@#(gUXOHF7}YHqyYV{BkU98qU0kKmj@5K2HgIv z6(m%~sU%I4K@E%7#i@w>;-{IoA?#&01Gr5IX1DeSoHGp?53>z=;7wp zfO|HMno?wsUuXy`@wW&%x7hDZgUT7g2i!f>Tk0g%GpLx6=_p5S8e~2-SWM$r=kw(< zDYp5YjrmC;Rd;6r2f0N0`6G8qQPNgovJ2<9S8`KVgY*|oo0k_)BC9TSna7%UoM%^? zOcCJ_;qC*nm)#frw|lmi-E$se%&*=s=tpT!xzykU{Q;UD-%vw}So00#rioDkq72^P z)Umt{y5N?);9w5 z_!{W^8&2!+Ss4HH2(J!*W`A0k5;{W$UKHP-#Jxd%O~f5nU(A=oE6@A=Vd1@bMF8z5 zXrBbd(QXiVJi8PXIbTrdU!6wSBDwA3FG08g1W{DRAxGONY)n2$MTD_wVc&xv>--F&kmAl!jDSV8r-&r=}vjiBl0F+9vL*xLiA8uYGOyFc~|x^T@eS23~&TG7NU zUH0u-?dl+cp^FLsVc%a8lq;z(bEw2F&!D;0L?{SGE3K&XkoPjvL9;zmb4v8`QW^FY z{Bj<3akm-*#mPS&`ZIAcK8pEw5y6_zj%Q2&#uEjuDk&U=@f`?Ppj+thz$WU@V@Ch> zR&$sn%BYdjDqfV$l@&5~MO;0db_d&cP+t<^sw{!@fm-B1Yg*B|44FG7A#bHmS^R8L zE}uzZ-N#{Aw!rTSsq}j3!4E05I15Nf%8fqdp*xwThs~2qi$+VOLr;qLYS(vn&yHqN z4V^dp&spEEky0a&{32hv2_S7Z>B@y%)6Q4Aes#5iMugk?Q(>sVA3J`4AIBS`lIaC0 zn9By>{1=zk1zgGjqVabnG0HMir^pG-wN+#LnzQL=chRTvth(F3W_Fr%>?M9yzVC2& z|K&5Niz;vQP!kZNmuiO4s=@XlT#BLxLA0Y+8~9ra=6#c_5-wD8HS>OK!!cOY{Riz! zVA*ATgVg!5n7c+cBrP)4+w%{mhimwMiUXx>LFnYH`PH@p2+>Ugjb01kTEiYBRhp^D zI(7Fc=itm&hto4xm4Nq*#L+%|dpe{1)$i0k!W!l_CpoIfZcc;W=x*px9>m2A>@-vy z!TYwhm+_gflLkdW~_Y+!S-uG*F)xaaQ@7LNdDx=02+!7Gj z_dw()*0p3sWtb0i2_Ki3TYFxEcFX^0h3b)hHkeq6sVn09?eAVKvEvr-gYh=3OI%QT zE)kJB<11S9Duseb)<{$y7&|Q4b{(|AoG=!S?N+cgdOsbAk+)SH-72)-5^wJ3uc(_z zrP-qBu@JtMe}?U!bd`PA&GXS!$6VyDho;WcO^toV8s=f23;`#gc>yT6wKv2>P28SvzO_h@0 z=UsDon2hSqO4EM~k4QXtVO=+#RkPoM3sBLlXC2LQ+Rg-y$9LVeb#kPQE;SQ|ZtUwv zT{F`8w69iHlNzD#uZXpZG)@FT((zs$SFTGGc`MQezlsFSm;|I!e3rY8=A727fd%no z@gb&K3Zu_BUj6}yc-16mme0Vy6 z?T262uX$1xbEQPNKWlm@@R@ z&~;ZP@8a54nsB`>T9et#hO2?9d2F~))Z}5?;zt@cMHfiNTV}vNBeMp?IB_MrjJ>== z9u|iFsXU!0bR;7+srSU|DmviNZKb{Iq)KeGpRMc5_iG-t?rQFjZf>vb zJ;@IQo@9Nt!GYLl4Xj1+Xft_oWg?|{SLp|;MdUcpoOGBlSz{x6r^J*^TmpWabbJzP z&)Q(~-6x|8`jRgkdR!|^VE@h_$7`gM1=s z2NoMrBZMhV+XMj|TBHl1AL^KgOoiE52uCxo3^(!q>)15{QeU5i{EVWM(BG2~2upH> zzx5(HW!jH2jd{zD%zoPv-vVN^l+4n77?^y!y_VV|~5StHd6g%kPiM3T`o zUIWCs>*DlO&e83d8>SZO&~ePt@Z!e_@@`LIo+>q2lqgI&qV?RW;vHw^WZwh*1*mmq zZiLyESd9#X5Q;VGR)63AG1RkY}y$BKz|gzUOjv zX?GZMrERr_^gZ)|!3l+UCdc(Xfbn4_i*M?UOPPrdTGA7{{KK0wqbJbYv_~_)(7@VJRaf5VtZ>D@d z4C$@-eZ2ZmTB=P(=EKxI?I(ABK> zgY=+_N8xgnbX>Hk0?H|e{1xOeq$;;TX5nqaMO_Izr$jCHQl*qC$3+T9abY#rt!#H& z@P0%TImtMq-hAJ9@H=|Td=ojJhP9J5mCH7a+y70lTT2aZx(S6E*k|bT{@E)k;cD7Y zdZROr>+C2bQ^1s6mXPSwQ6Lr3(+`D{$Gby}j*#GO0~)$5`j;-)Mq>RmqJ||dlEyhm zksv=4nPnh#)9NnL(@-YLQMe$PRhnN&uR3Z}w=-t+og~BHp87Yxui+btG%MS|-u=yn zo4bFzhMfATCQyJ5#4Mo!qzC^8zd%60Wu%g!M@rd*qHEQVvQMp7+PWxZdSFn@tebOU z0T7yb{;hBMA`9dN!$F0a9uirXE9m0*KKrJ9+>@$a_I5=n$4c9LPqv2h0dkoYpMx%g zW^<#=-vD${IVG=n=#uVvhV-RhExq8=&i^|zP;zguj#v-p4)2X?OMXJDxe6KkmkI#Q z{*ry!<{i(;C`Wz!T3V`cC0LDlyxdSZ<&og27)|S!N13N9001BWNkl$ z<^f>PBQejDX#g-Pb^4kOqu$S<8G>WVKIb5EDOy)AYhG$?ef|2GU-gG{lq<^t03K%P z*Y!svz3%*vD+<3j3~Jl|cUmhwR-)g6VE5~|xTuu)JT4L%hTJ|V6-CX3o<@GsucFu; z4D|ilzHISS8V^%813O`WBia?;m3U~|Wy=1pr`;IPi=$T>UdZ50hs<3mPU89TEGn$1 zD;1|JS~G3|W{TkrZ)Ur0y%>|IqHpp z!H}()G;lDKJf)D{{lM;UO<$oC2DZt@&<$sXlGa_WTxY%xomnJ6jsHu3Lzi`x*uS%j zoXaHv*xPS?k2xTdRfcIW$N5Yt$FXnor*CcOeNpdCZMl5o`ogdHTpeXv4gl~lO24k( zC+Q8QTer`eanj5sg>5aT$z2b&5Q*Sfcx;gGGpmlt+;m7ti-kmNiy z@I%=#B$V_wa|5*3$2R5jy9(GkZDEV2eYo^xUwJnEl62Qy(no)+8PjE#WnnbhPI-6C zgKUEV#bKUtoe)^8`!mP_rm@te+tGb=MzXEts=4l(vjtPI*d z6Iv(F^S(GBPf$Ghezuk|huQ{GTI**J5I|tPSk#yATwnN=FVRtkasYsbDE+!VThe#v zyi)crsJuCWNjHZANl%vN{%c=I?p3UtH$5Z(X@3qCZPsav&KEMh_3*fYvQMNhRa#Z5 z#(~Ctsn=6cQU=8v-rAP=4t=Ei{Y{3Y&t`-qYd_k-nC(o7q+vI?N!quX!EnwJl$aAy z=ySV{aWvHxmfa5$_I0h^2q0&WvLNa0wzJXyvtO|r|0ws~SI3b3DY79@!&)MJP8MlklR_qaG>rN8 zy4Oj0B+KIJxR1FH8B=ey?n1@ec&9hLtq2Vj7)ULFt5;_ z*_i>Huc<>*AnKdETf5vCfT+8vopV|)Bz^b#8882xI!Y-A0C|f zZ7Ac0@m#(TB-)hm%8oulN#u4R7*F>&X4G)@xv6YlbPubyJxjYV7{%k|Y>WXs> z=&8jV%9r|RZ%@|w5WmaZ1|WOdGppy%^6|e3&55~gvh0`x3BG!j4bPyMF=aFMCFU&W zZ~)(o-&t}xT^G8A6x#a)=SCoETpRB&TL(&r} zn%zqLUyK3_I1N~OrZA{+o_lye-4{9jFDMxc**%6dt`i`@wlnU_eYdSE-ycAr5YLV4 zV+&i*#KmC4yFK4%lBMxN#xIL2+d^ZvUFHk6>UjvlDQ=JP`rn03L4WDJX0~&y@^he7 zDEeNXllRj5(q4zNF#)v5sIZOQ<{A}n_G4z$QJhj{JF*V|2M#dvddwwK$_e(G$)c+o zA;>zJBVqikpK8+6pMJIRm!x0&>!c^$y|v3gQW-bOEDZMc!<-Dc46$~Yyvc9{*JJ^O z7<&)v6uF{3`xhD*8E5}IOc>7yU_R3!949g}<$#TG;n{&!FMtJdX=Gco?k8zI{m}ZEU;SG~LEo9>005KHuj>;e{h*{Lo5VLp~s(x{#f66Vp+&3j9RUucup|<>3zX_DS+PzMd3(v@K-o>oDl(Qr8An3-bm*zFvS)GvIr=QS0Sg z8W<`AdLjR|no-?A2f^WN$3x1U`3NyT3{Yho2z<~V*A1MX{5}~P0&CMtec8_2!1|(G zQ@)$bjREZI#pdzv&+fVXb=mt}b?;Nu+Yhh5^DiH-64n6#hSIO=xsv{tdV6w>@6t%M zeSwxkEG|{{&X&yP4h6!+-6xB0f|!H0buXY8&26%W63WCiQzq#RMKx4pu~=d+FAO1> zgHAcXP}3`S&oLF}X{RL1_Nz@s+)d!hkqV245)q)$(~*5;2V40GnR;kh_QKYYfI1o z<5uWu7*&o}gv|211sdl|4Ph3p3vLU|`&Er4OJNUqjm^&-TL<2%>vK5w0O6?u-eO!i z=WP6?ajut88rS76hu+&3=RYU@_1@Il<(349XF1asvN?z6o~qXW&iWZI{}|oj0OeZdj0@Pfv?`>V)M7TWy}r}nqF}#Gb)at&NjnER3G(nfja%@i!&A#j|-09 z#WSGSjJRi(wkv7Y@8OUVs<8Xr5Rjsz7hSw;c;npn>-2HT%$`XD+m~%mF|Nsw0}UlS zmZbb|?B5YQ6krsf1;8(5-s-jGnUELz6Lm_UQlY$q_26|b?|3FbO?)q5UNn;*3~UL_ z5X?q@LP>}5LR-pv>$S@?+w9{7P~iru^mDuJo@YF?=g)HQbGLe8{E(RF%v#LzT(3** zZ*HTEH4`$&S}H~zYch|?ULf}v>qy3RYQEcVlM~O~2sxMIlTl{OUerxzhu0QBkI%~- z?K{XWa>g9-5C-)RuZ?~sLq)K>sx6o4_4n=C-j{PCAOQ?n+IVi{Tb0Dq^I43KB|sg z$cOYiLA$dQk_I*|=T(Gm0K(%t=E{UjbKaY=o-siL;PjGX9E-7zwoFQbT(%kg5Y|`$ zJJC;LL1OMR92IM+=q)%;&RTeAtIRDhA5yXajpHKitR;aA*AC~~d5nLpdixver@!in zb3xty9NY-kbzQx&0ImwC|H!ToAU9&J|Sy(V&=vhNr@jMnl1q z<{T6h4>K0?R=;}=1F#^|5z2hxai(FgezkQspkxoEs_8yZ|y0G zRv0I?hn^M{kbAx*eh&bVAQ4_SWs&h>$c61mqudQpwTUsOv4N7$dI3n1v9jy_%@=qk=1vJA^!=Wkzil2zUze-5`aPWT@xM{IrX0K!4>i_@ zsYcS%QXT*J-T_=+?36|WdNhDTy}TAcVSnJ*Cf_C+Zy+t8XJTk`jG-$U+8uQh#E_{8 zhlTRY^%ZS`K1tvcV07KtE(v`crC(Q*^j9Rk@ce-zQ}-0M(y(aKi;jhBaj}NQHlTeh$XG;NC^QnX`^wwXfad8j z(Kt}47dm@YQBcJT2AX%d&@(UIA`Xp7FOVJ}Ku*ehQ34=(&beqAI%&I+EZ|GK5{zef zT$4(m03dws8QDqU;`4~*rs4uHAS>duCck&3PUXlkAApy6fF&langcP!bpe8+l#?PC zlXp`|?ptnDHjJ#zl?7u;89Eyi*ZDTrwr<(}U-f|*f9ZT40A&!r3v;nnV4TgzxW;`Y zbbxUv3`)b=Ss-`LKY~WAm8-Uoa&6KBFKc&!tgQ_a<|Ef1^~YrHW*&-xZjN1HLtA|w z*{4Ca3+~%~6C7H&Aie)&b=Q*zz+G+}n#SKcK^8qxn&GZJ5 z;5KV__VF~Kf0zB0tqma!8pSqII4s;%z)%dYElhxc1oqY0fNW>m@AdEaEY_dbq3xbk zEqg}RNdp8%gM}c8^%fcTn(#OD3o@W_Y!ffNursTj5y8?%IfR^A-*R9Aj4jyb|8#x8 z<=*E?+LS-H;+1WAZI@#iON^V(-yF9S?jb;+9xy{p1Lok?!AaD`Yf|T6yvS~qkP!`^ z&ELjrRi8V3l{G*|$U#Er2TVlULK9?(1_sdd1=u*A?|x*LpIJZc)nBALtlZH6pkLSLNqS|akmAdjk}D@2no>3BSWx1OvM1x@D}y~ zN|efApy~-A!bm!}fkMlJTeg0aqAYRQUbl&5dxIHi^;uNbQ5R*)xG=}o0iW+IN!hD{ zmA>qT)PJ&XF{dW;ZWhuhUL3~A+s`(Yd4%WD5V;G~ecY>{{p~L@$<%zxr&Az?NhiicIo#Q=oR1#PNBxFd@fN!>o zdQAH~yf)=3tQUD<=LU5D~H%%SP6VpzI{6B2hWsL7GL!$C@d)dpt-oOn$K`=+v8F+s*G)M zA(jQjd*MNNUl@~*@8X>U^jPBA?wuA-uGh27lLf&wJXy{ce2Ld>GH}^u8!yq8Sik@{ z#Q>UDBkfAMyxtTtVjGtC)&9qI`9h*?+%B)}^nbT@NTV$HU1UsOUYjf0=tqfbQeGGr zfI_vzTu~=r3f@Fpuo-Xg}lrId<_so>T6_^D-9$z_g4x)@w(&m zY$w^uDKLW0NXE?Ck&csr7ADCOJa4kvjeb{a=btjKOP}co8R&ArTpYL1Yp0O;BDJ@! zpZ4nCtUILKkpQ4y*T+iQPWgk;EjL-I*NX{(sB%^^jRSYJ9Wz2@8- zMq|aC2ptexN6El&ol0lV7!X@81+ey=NpmKzV@?jTI6wbv@b|D??)~5DXt7Otrt4B>&{ZL$ z+?fzEf$rVUU$|RpKd^rKt3GmG`R!HiH~`QeSe`EFht%77uRr&hN1362SF(dL*RD`W z@$!Fy3y*&lWx(&Yi648ni&3l#r|esoOp(Jw$$GZ<7ronfUfEac&y|Xo!G0~INFm?! zST^tG@|wm|{6fB`+IN=;7j>ZlqqxT!%86bU*&e|lL9xbn3);Ed!1hW5jZ6w1!V$|Z zhXI^SfJlH%f{DR!(GbI^`}y)7@)6n*pZPqbmq_o5awM3{*W;p34F9??$=9qA-VBN0N8f<)AJ@%5Pi)27B~#?Ip=e*Zv?6F+>lFQjG`TowI|*MKpa3+ zxAtlWBROZVZupzwi{gw-=1Q(V$Ta#(hOdk?^6-sMEI#79hRV`%q{5 ze=z0RHR)qMuF8R4Ve#~%uP{=5+56;kp8Ng__&Zet=WCK4dNG&)p)ohgiI4%X47@u~ zps^jo0NTfRn3G&n2L6*hYI{41a};%LpfG+yH|9x`OTEWR^V1GfMrd2npWEj=04?-t z$f@jp;fau7YtHStPrd!M^;2K#i|w{5cN73nZ?9EvpM3rYh*mNL3L{Go`&>sgu=Ggd zd;ae%!R56pC;YDDRgFxc$fE6`q}Lr#P>IpF6xtn1jYvKf2Msmb*s*B4eP@PvU=E;c znEb{*GQ2?4&$%gwaMSxvl;t$ zhT`%G*8f>OQ5VL{LFgso)aCi~cB4+n-1p2UeLIQk`g)?5$$Qv-H!u(9Z2u3o#Or}0 zp|ym%1bJgzi#NO0&jJ)mdp+4#Gm3dXjXN1Kf@y#bo+shs9Bm!?0K;EGNi3NAF=rAC z0yLCoh5j&xp{#iW8PYxv%b6K`hPF5Q?ejC<@K@SHeZjubQ7YcODmdUFRb*pA|5ecR zVzvG<-NxmP0Ra8Fe!p7(;(ZeJeQ}i-tCYr$KtNJbO7w=DqN9g;xhYT@zAR9f^!@|! zb8$*Ynt}3-|DDbz1y4mhWbJETESdP!Yg}{i8euGEp6GhoPd^)86)ZC%Pz339OuUQ% zz)*GuBv_bdQ9K7KE+}88dW)Av#snZ@$_g#@hlaNp@n!cIi|4tZT`}g9Hiz=0QI9xr z@m~8b=alK@l@?>(Fn@i%;@R0>TwCT`tL=-iqY>fUUQh4e+5R6)x$i!-t4~0ks^O4q zYz%}EG_sxq2zE-PuZN87Z?tyi0rdS%p`LG(hw*H6lx@kXYd+Rdjt~1x01*Izb4mN> zSr3jnrvV@GJhX#oIspI^o$CjzN6)tGvTw8V11M`6^!Pk?^st>Ud1XALef*0~`HSnP zzV;=$P0Jkt0Qz-3PtqF)+Doc*St?s8mEiyiw!dkFJkA}Tr($3Bd-+a*qTqpG^Cdn| z;As>rqn8WCk#_=|X6*~(49{#ujzKZb+x2Civ-WGF-stBN3JQS8!vsYs(dffayV2|z z4`)W4n&lYe-&7duc+GGv)@1Lm#u^!U4lhg!~$4HS5;1^+ILB^`=rBlVE!_|$QbHP^(s{_j{n_5R!P z#X+|{0O;5CVUpgi-tcPQ#geyZTZ9&=d7$M55^Md!4yN-MrG2QYLc9_Guj$s$T%r!S~3<%Sq2DQccn{K zA-(ofecr4MjAwgDf~9S-+>Ey(-y-GG_i@Xg@P0SBJnh3j@Z)_ykkXoT@4Z`_-4N42 z<_(i;1S7hYI8&eiN*X`NW(q*Do}%!ai4)!)D8Z<0?aSRtVDHFY76S`?mGcGqj%T9j1r=N*_9s=Wwrn21}FO6;ApRo6Z4hr>J%+dN%w-1%{ z_VrWmzgxFixvc>}zph2ncdECK-VNBM$cjRv@!WpXVDlcQM&r8hs8iX7qJWWgFQ_kj zERseh>W#_B#5z8cC_iaoTcqiIV(U*)Xz>21oZORSKiu<;%=mKRo*B`&mL5wa;ZSJ~ ziie=4p&hJWD$2Vk*{!X7&1wQ2JTDAicbYr#XXEKvaM;Dac|BM%F(?RfUlH8 z>1b8_#rs=!rCR`B-|r=`<@4CTV%*FqPC@V{-(<3E9#BpUAv{{y{ts2|yLWG&1i;fr zqcB_33Fi79IxAiy=d7wh^_P1^VGd7}l7})&{6w3|7Ub<2%-(0%&=ZvTT;}s0pvWcZqInD41q5e}T`vl3!x8k~*3+^4?0as8C9 z^#yrLm)jZuNcxNF%}#cLN6VN{pDb8TB&jOR;(7I5Avk@W28VPS6n~yXHbK&nai7#L zJqky?Xpgij0#sye4%x%wpn(W4g@%|kqbDNy zK72l-btqnwTRi*nV&BTcUdHu4&#kHkK&PYE-Z+dW6iBgtux!gHG$$_>#AIi^nw9WCCtON%cJP0w)e@w zY3M<+m(*41_%NKf%eQyaO(}6ZK7b-^uoQVTcp3eV*F-C*}gZ zHOp-a0QzGj{aEQV~Fj3?QKI6EEsW zn%dgJlAZ_*ds#Tz6&Z64@)>BaFZy^U4cg&;4t5L$rF z%v&O0R?)k0s826>IRudgPN&qrMxDgD>A;(Sj(TAha$!h-OdscVTTfDd+xp30_p5Xp zl-mvf^y_+(di!2UAGTA~+XBf27?ai#W8&{}R4SFeb|agMgo$gYi=hDbqPak3ab^b) zT+Ai5+v@Y2;ow>4T+mJ>azPdLz|eGh+C<8ykva|egktj0iH8TsatwwU<5c?GYMmY{ zWINdY1SLyV=*7Ts%sYPgJ;ux#lvJpEZi75I*UZy2uPNtkDUf6K^`tW2=>hg8E$>qS zLi7}UOzE*Y0EoVMoPXlomFuO=^Z%;H&+})w@43phCc6}&7@Nz|K6VS{U^#seh9<`W zGRgX4?Ln?-XuHPsfqTk&is#wdWZ~|DNY-TNF6#ntEOB%iQZu@74aeRLa-8MA>!tJO zZDRxSlt0cz>ef7e!wfwcF4twwppi+vl2w?qyLiB;|89dKD|7zOM9JMY~?x2j#~4%SXg|yMxMTP z8f8%G4CQ8F0`@t|u&}nz^85fA>|FZr001BWNkl3XF=Uc)sX&86PVt z>xq#n?@3T)a#Gnh!~n_Yz!M=rV;F|dDiou6+1#5ZjuN_BJw!xSX0PlZWdajD26!6TF_~mM`=nn-*m22_zAxw)e1BO^dG1>4%SqQUxnTq%QO!dRG$0NX2G316 zti`-^4}(x9X?SBh6BZgh6Myw@9)1E)KniV0x?_J#X3grLQX}OW#wr;X^G^1ER173# zTD(<&vTUO(-KIKk3@EJ}U)2OEwyhX*fRmEYx5+u*b_N+mdkf0}Kv=>SydH8d=PYPf zaL!=G9Q9&eIc&1LkT0=$Pke zG@*}@d0OhpnRF6{#5&B$vX*^W1GgAgg^`5|DP)*MD!aTJ5=BO8ZpVGUu1^FQAPEarHOVUKO>{{_9TP2-0}Kwc<`)=Q z8nH6Tub|^34fzRtA9(>Zwy@|?cz+WO3yDTR&MQtKOYfA5KNMsr6{2SCijfQojVV7% zMF{y9$}|j2ymV!qD=`{AG8vRjvJhDKj7yT)NN|PFMJlTmecI%iN-St*CMP)<1$ZKm zqBow5NZSoY$jup;>|;E0dq(Du5S*C@#5#&hCxEJWQyuN!48&8Pb@O@tEYJOTtU-Vu z=7&%&xpguOG|uRkl(G)vH_*ze)HA_fTepx8#UhHz(mOq!V?>7D~?UOYT_T{)JxX3))HI+mh7aQyWP&yYHOFnlQ zKPT`hG_onBA`i$h6ly42_p0}Y;cpnoWJ0vpSz?)7=-c1Eb+IHx3I7AA^`*w+d40;x z+MECP-SDQ&cLh1dc%yAwJK}r-UK@ZS!4Go@(DxksUfbseFF;phUECRtrjz<`vzTcO> zac%KNO-C?4WxY_@0!!mX*26;vk{daEZXh-ccW>oIK6&}Z2J|8aTth=k`Ef3?5VV$c z0DW9uumw;`J-`qsbtIh$GAKE2n>?xe*X6&G^xx?gEVn!W==YzVr}gwT=K;OkCjt#_ zDyxa-=^#PcJ#{9Iw7wtiQfMI|JOD*qP^0$y0z8mbD(!>E?xBRzPFuKckW*1#)ViJ0 zPAU`3LbjU3%`rU1moczV#ul(BzXG;OlVj1$zH(= zF2F{Q-wA*3Z@IXTe*MSpioUE>n^!reTVF|`6C{V++Nzx)Faw%^iwxva=Nv(IGF?j@ zlMcBPKufy^P3NS5YMWoab(YMg*GqXMo3)+ozn)5x-}$G4nLaykiDTgOKj*r$mCWC0 zw#Hbm+F5@W28`&=nu3fs;zHPTc1$q*1W(C zJG@0-TvDZr7_*OwQn!NaN+1*|4LK1#y(UiRG}cfqp|A`3ZB}ofU{hM8D;Lntvz&xN zUtglFFm5TIP)O7^+1@B* z3aBLG+5m@Hs4NYv5FnH|qK_lw6kyUl%~H21$x>SVw-^losI1pyMc|SE_XH1@K(nEa zO-4_B=FJ=PpOrWkm|;GthP-n8Vmt$k+1N=+JrqYxMbO z#xDVcd+_jJ6KH}B^WvT}*-);X0x&P!6n07#BAeg!#^)+-E z*L?JkZRB|YbdY?`Q^(RlP`g|G^j+)o{|d7oZ~Age0|2%DYDxFjw>>Qy_Kh=6e7^7e zJ10sQq|G>!8s}NxZqWIgvFZd0Sa?yGHO~c6kZzw(SWeDz&OqC{6pUaI}zrS(Lfls$=J^(O#`Hsc zHYB6YfT;F4Q~Jf{bnT}fQin3kMo)jcm2D~WQT6!_c4Pj}l4;!=m(KIjx8%l)!BQ7H zn8rBeccC|0{VCgB85W%AfVTctslRN}@SC=@hrZsoorcZDyK3<4 zT$#lpEej(Sxg-AgAAll}IG?Y2?~vRA97ce_I^EdJ?qU3TxEAeRZq(2K6%k)|oAp!Y)WfJ&E?Xz`A4PXTj?sdG$27VLZUr|c8Q zSTb{*9=KDC?%kCBE%kXhjwuYthVv40zeQpe!yYJ8PY!Y>=p*=y&%)3w>p9kcGbj8# zSoy*~CH?%rJOAy5H^3#w*<}-j-gvTo1kbAV_?(}OXNGXrT`v{Gg*y4X@T9q32IEC0 z3GI&W%W=V`3yqm${J(7=wR0QcP{&f`675+qXJ>lHLAC2auKO!>rsSzMed_WLzVJtN3zS=u0q763XQ-dPwJxkkaDx%-n-Y}j;tfxXNISM)bq?&Z z-^0a2lwS;m_GiO@YuxsCS)O-D5ECHk0gN-nBp-`+FCpXu$9 zqK7<5BS+mq=XGmy$*?y0I#pQcIDz3Y15b4C6llDxDV{fs0~8g%pCt`0H}D!<8pj0& zn_v%KMuwrNkO5xTMJ1d15E7*F;4rY9$IJVa0BCrr64)Eg(E(6luCJ48v-DlY#lcWrqw##F?m1t{=rF`Ec>h=z z(Ix_BhI?jVF-l~k|Idg0z+obA99XmHBl|{k>*Q;GVqf&7&)JZ4ZP<;nF7FbNYRO}b~B;y|1 zzNlo<&Wv@TYE>6vOuUj&9iLN`Ab z?R6GU56?W`s9o{I*qnCol?N1sLOY>-t=8#Eu_*aV81{+5VAvr4k65+6Y=}TOf12TB3xCd(1)Y5*OrG8Od7V=ia^PF41bM6 zK^rpz;i80N*H2?|;hJtOiGCW7STtXT7Vxp{~ zj%Z_i=L^JH4SMyZ^ieJzIv8T|MrC`FQ79}@a|`ZI;{^lhfX3-j$}ieofCPYKD)d(G z|Nq&0*I(VP>%4Ew_3lGT)}1_uq$Jm_Ey)sPla$>!zP5>*I<=72MiCV5Ek_2rAKo>_ z{5{W{o7Z#5!{a@HjlK8#t~J-ZjLS2gG3Q)j$kLE=eC=6Dxn`SdjId^cdb--95D18k%W+Bh zW_qNaK~QZrHJLL3T)WvVfS>w5+u&*6Hgu!{LASl;*wgv1*38g0)b;zH+Q09={-Jol z%ex8y^cSu^6n*;}m;VxBLNSPzi6oTS#pbHo1rTt01q($Dpk=h>3=%2Pis1U?t$y!j+MngIk3@Xz3F!SX<_r=d@L51t zB1-4vJd2w8{HK|)|%)OVCspWc;%l$1E*CF-S z_p5{M$F*D|kO1a%7x$rl$~6NdGH_mh-Y5NzF)po#z5&$j+i_i^t?x72g5PeO^v+GuwDUwcraW+TP27Q$)@Wt_)@nD;U!UWx& z?au2G7}b4;63Kc10}a_`wq4%d6&HmS3bl>jD~g-rC3(Ldb=*hoaLT&|0Q6tmelVi_8JPr9X_u?iNr|y+Lm6o3X;?j?F8U~7Wbqv% z#LWyw2^#@I8r|c09!2|>6~32zhXT_fRv%*28!?2ID17OV5?q*o`}I6bMi>DYFpm8O zr{sB)2}C|y@W;b{!caYa%z7JWEIGAI*kfjaB+!`LNgq`?@2 zHpYdHKF3Sa6b+6iWhW0Efx+~}ctVv1WH>==u5Ga>i7X5w#FPQj- zD$jg&0Z!qpT!+ZMWDzF~+L?~2?ls#W;D9V3o>_JP0F6N0+Yw>l0Q21OBhLuf#AdQF zoOWxyUhA7tdas;6#!r|l1IPqG^)e-ZsEKjI-xVb6W8rfGWwHYjgyH^r5mtogX^v$+ zH;+PRDZl}HBF|=evHk4+{eSa2;{h%23INc5ZF^5d|Mkm%=5&bs0!kr!r_*8j7B>nM zJ`-BLg%}0-4u~`zw5%V10@NG7=y+4;yZ)q7Z7?2bRJJpmqa#oljolX=!bx&pZ!LOc zq=5U#k~j+H7;^kcpK+lK^UW(|=a|+U=3|KNF}^v50>BK=mBB#7pVtaVNMN?%x_-z_ z3&|xGXtR?ReJuf_zr2pCnRDC%ed1@H)0N034QpR~o^!K^;u#H9n^VpKx+0=hk8gX# z6OTU}IsaAp_D@G_N9yJD%5V}9(2X|qu1eFnE(ZwxE@0e3 z2S_dL$v&QyeOl%FucP9lA6xxew06Gi``>Sw+~qylOgyXDj0Bj;j(}02y>xpl>s|1S zwCQ#5(ouf~K5`%v$N!}3J%VEtBlz0UMluI-Fj|Y)Z)uy$Zl6!lxBq7Up1*A<;=w5I z3IGt%|6=s*GZNv^36x-CLd3~*6&mKyQ}Kij=Djq^5t@&RvEVF11;nZ0GN^t0EeLlT z?a6D1z*nq5wG~c-kz#(v{32bB8Bo$ zDdM<@VdEHajL9w~vvD5J+)oeYD}C=}dGC87KJhKHla~DP3T(x=hv}7m8f#d%c1Uy) z0G|Na0dau}6i+vh!66e@#q~A-?qM@{FM^$1;3o(Ei1UUHCDX^?sZ-VfJnOs=1Z>$} zj)4RG9Cv?RJHM}SVYQTVTcn^4?Fh60L%1~>@+ z8~qeY4~z0Lnx&)Rc0U@fzC|sZ5$Ddx{1|V}Lm4#~o*b*ZhSxc0q0#ZQKq_8dSI^zm zDA1_oco#4w9G5#-*n05iOF7z_=lk7`}JVwlbtaRn9 zYXlO=<=G@z-)=f|sYIWb^>R+r{x}=S-~*p^351T?nIe!r9lvwMKT}-%KHJ5&AJ(gT z^QUe?Kg?F<=ROy5?cy`Hl?Bk+i1>Lg$1_6D%}9_O5%pdSVNoKV@%;Y1zy67Mz{&#+ z0Qw83Q$+lFwEaqrKadtDkdYjn(du06`a4D)k{hOig#CS#-HdVc7{^d1s9+UzmWRWQ zfRJrtfnF-Pt}+49w|njH9yMaYHV%o#OlvsY~EM2Yb{bY{VfGhaSoHzrKxA|5j`WOR-Sb zTuVD6demmdqxzRkY-7CK4sk4Gj0{{9g8(~IIsXJqS7SV?UtApCMp~9OGsm@_wY@j? ztG~5>&)@dJe@Dv$4FDqIKa9S8k09&`A;y?Mp{8O?h%bck92x1`yr$mzWY4NM|Mi#_ z{^j@8&d~1#xX@4-y3E&Ema}Ui&e!W9TX6Y84L<=;DR#_tNP8VfH67!+whiT>f#jC9 zqRP^6czbOOh3NtCo8D7!TvH3DU|g%8?V!%jAu^Ed@Nwy8v~oQ2d3hh_RA|LM#_N0w zfUYYWBd7A^c(ZSm5eIz$9daV4Zmq9PG%f)jG^!jgesrU2qV9{LLaPsJSQOKVYP3ckp!Ky^pE|v zdlSeJR1lCB`0C>)$4}Bo3!s(tt=`f`u+(h+M1IhxiZt81@2!6KqMWvfr$2M~oAxB5 zaYlL(usabomQ3MgJrm=SP6?o~fI(Z^DxSA-Ewf{KD|&{KV7g@O($_8kBI-C_zTQ_D zMlu1_SJ;yZZU|7E74UjsJ7_n&kJmB^wyvg5aYfw)_Az%J73+65qsQu(A%8k}$vZWroHPHNf<76s)3|PoKgNyyf$`$O zAz9aE$|24z#=XUAz<4mtP=IS6U}it)?gCIWbPe|&eUkh>oNj&I7u0`_F`TJTsc8%# zGt;6#%%_Yi3doxyid$djBDjklNjUuux|BxJ>{9F$KNRr z+_jPVo{J;p^|k?Los1XeI16*n<;^u?X76s0Fp4e9pjhwmuW}?*b{?_v7|$mAmgy31kuU7q~6j8MKnc@o~4@<0NB{=(^dBig^GIcGReQbt^nxr(Z53m1@M+e5d9gbGnA zj-Td7hg0a)hEPyz3soQ}guGvX0(m$Iz7xB^3KTP+;kj-%M5Gd>eNgZmTXC$YCn9Qr zV25H90KzyhC(3y$AOS~R3Ue41H*k4PZK2FIH+9@K7BCRyXi3!(@WH8W05vkcN4%Bc;zvwW@t9M~L>u6F>bWMSNfWLr}g2~OmF7RR;p7Xl=X`8nOy zI(phC{@=8ovae0sOusb1$DiK6`@ef89)R+|0f7E%ZHvDB^~-?ve-W>l8HeKv+%0=HI`hU59_usL}di%=*2LPgP|87M5LHVo^UGF-X zZg}&v6>wnjH=PybK*6pzlS&ZCYPThky!e2)OzMWE7g4UIY#O!~EJj(Lph zrA_BsgZ-$*HA#10<9t1m$uSbf1&WsAManJNgXl4iTn;?%n5Q%_f~a96X!PjZGjeb` z{&Mk1i6mI0bI*1QBNEz1Sqa_LP)OpKhs?;ygb^i2bw7YK zpCt?E%*K=stB!MlUw0X;wx85)aK};>+P7i_@cM4190jN!dM_z4g6PAZR-3j^7Psr# zDzbFdF@gu!8V6`OKK1T{26hg1oHm~0JH1WS?AFCM{gG(>N8=qX4-^3CFPz>V(SBKs zVi`U-(mcQ+Kj*1U6mXB2vq+R4QFL6=cx^{6%M5cV1034QnRHX`yq9&kGh4mDW{EO! zkF0t>nNGdCDR3@{`kV9DGJ8evJmVLU~n9rv8de# z1ZRxq+x(2a4I|@JWnLrEwE+Rj08d%-{xAyS8j;JF^B9oTprfa;jR5#u%*QmA7{@!e zBYysiH_kuHGfzk9Njf+JNXlQ?63R--S2~x7r94Gw`iZhy^LUK|bK4Rzm@GQ|l$+h0 z?jaL>KOLv~7AcG}Mc}2qOjXLbiZ5 zwdzit1&9`%?&EWjcK|I}9LqKXSQQY6VgI|eKcC(9;qZN3Pu{m;X9)c3=Nz%0Uf93; z?_k@>+g~0i01(ms(}?)kaFi*~%zYmSk_c9!T?inZ5UKDLJ>;hYrV^crGU;R>gmk1S zltiO^J{2*YzN5ta-cu0ip!mPkB}7*Ltwa%IghZNE0%M9?5X3BpWg@j{9kW*_A?O;@{)@$X+tJPr-cLr*A+U% zbe`gDA`}oDWm>}%DvxDfuGA*hvv~IXxcySOVR{8C@#M8vyd!sAKj1kO+;G)22{%$<`h1Z`&)!9{<1%w(Np}8QKPQ6PoXA#m?a$o`zcXmjf*-q-vTFV4gg4m6)|HG z?b7z@FK9ePsG!jj`ET>s)Oajb{z~4CihQ)I9OKdc+V;n}?dml1f1>yNoGgeuH%5Pi za%|&sMlO`IhSU(ha|0v?6%pj5++0KRJC#{;hWT`sA?I$|4PkwzTrfBN*hg=i|ErZ} zKQo+dzAQ`f#I!9j^uVJ$0iSkaWBxrkf7qXLN|Yt&KJ)%%%M5hKSnaRRm}RHYfu=rj zv7apZTY#fxOk)9S2g`7V#Uxl+Ewq-k3lYPv8la9CKt@=E1WeF>Za2*PH z4Cq9MVKCDuGN&!2 z4I%W;BZfg`k)u7$;SdN2O}&jaxRQF*TeNXT*o)}7D6MP;>M!Plc`ZZRc~5mWpK%7* z06jOI1AwT6&R(L5UB`h2iu7H9BnrLFgO8Q8A-@91q7n0)WW-nmC4EBuWgN4AC#{>? zc-*klxaU~rR>Em)ZC)hT4`V`SAgLD^43WvM^S5GBxL$(ec)mI!|1aJ+|5q!|e&!+v z87T^2%Um&lsQ+_i)L zKMlDTeTH5$m=ci7pek9s)FnD$*io{R*=Dilu-9ZU@|yFlwXCoH_65M~BUueU1-AOd zac%=BNeU;%wfQ&4X}>k@zqH|}_V4l6|Hl^Q%6;SR$}huqY- zpGhl4$U-w$3_8%)qEPettWz3q0wxaB41Zoj1e(twZC1HNL$Rc@TOX0^bj+`Vnl{Fg zQ9lJY>3&)81D2r_9xMW*?rA(+=^6c7v`eEX+tpU-6)0;xvyFaNTiciqIYS`e7?I*| zW5=jLqV%I)VMWeEo_1@aFb2oC)RDvlO#oHd=ZqlOFIj(qdJ`~uv^23`5pn0v5&7Tb z{9m_J2G2_PL0Fv0pgt zqIQYIQXx6Hi0+)OrJy90b*Wsxzg!g6CjL)HNg=P-=33q-LV-cmX>(14UCMYc4}3-m zTMQvjYQ9#vpF<1>bAv@y?P@U6ArUEO+v%`stWTqz^_<=6!KaA{=(^2 z^wVFy{Bt<2qLuO@IHW|E{vGMi%FX2DGtwgJj3~p{%(&)vmxqK*EGLwdE+=(T+(jv; zv8$pi*Mprmb&=~a+L2LZ{6l4jJk$B4S~Stcl+(_2wf6YWMI z0cTI;cS=0R&=pH^#%#xFe4k@j&kpNP!}l@TJTU-aM#G|MMzg)CQ_aE!mUQ%$La{VX zel{6Qv~|gbU;z-BB^k4HGKBmRT;;e>UgR(ywy}jUFI=3&Xv!HAi9$k+PCEae zyK(++xIFjig^sn5g>F)4tB$!MxXra9WlLZO=#VOq;3m4UlG3pLG_zZ>_>T_VY#Q0yDXj?y>r4mRoVr|&eT;_$CA+Z@ z?oW#SOyGwUAny|v?`VTBWX$WC<~`M0AfJuC{qcAk%R2@D`j;PlBwGJ3#CWDdBt4vi zLsZOts8qHH6%-<}ypAi`N!b_bw6mrqCm!P&@|;a4*^LGbyBqw=A1X9d3`U??WJ{i* zT{l*A(CKI^`USX=ZEEJ&Ct5!14s~MkCO@_r^Ha~EQd_^PKkZmBlRyQ}_qPz{XOfz+ z>xD^ZF%Fe7G%vEWaMC^o(Fgr03s_^^??ALDPGexi5y0ry%Y#Vm2ORy;Rz_F7T~7G> z{eHafn=POGrik}{1MH81Exrh-97U)+GTO}7L+4geGF{?`)9KmbIOd?;X{S^1lu|{` zy(0gh0~f%NI(M4oi1H}*ff0I)ajJmpXe*uOGI;Z-J)d>b>%>mLUT~Wt0ang~tjS25 zcQtIj&t27@&+Fsd2JVm`IGIulxHqq>F}5)pw(pDkcm0-8Gxx8&a|}T1KOOt&n~#6s zxJ+ocM)EzECd=%4N(ie_xk3tbW^fJnScb6a7oH4F@dX3QEE?5y=Q_i)cq!3u810 zj*?cF)xZFaIi?bEU_4h8EZe|mR(}jIO$Gq~iqYUCCk{TE!k!38F=DL!tz-)KK<#4_HV`8RNfH)(7*iXdm`dL*XX6IfaKm&+2Ydex;7M2ga~NM zbJKNSA40IAn6pg{k>f`b>(-A zRwm;ElwK(h8z;_7Db8s)K3-2nEoYYfOn}g4L(6$`CE8Exyqf1mX*3wQ^FD%s^DVtT z*{96y7YK52fk>u-ndsXvUQBiIUK&6jFEPw1Pd4Y#B5vO}|9euN`?Sgq0|1FOuK~fd z4Gw&rF^hmcS)ong4j#7auqQ3gtv{&$I^d^Do9pY_p$l{g1fAQEdy&Nm(Koi=L3q7H zDa?+sz;;OBf;Me;VaNJtNAAkWb_ihbKqXK=0?53!-U9$1LYsU}W|Dy@^ihIBbF&-t zyT)RNuE#Iz-}&D!Q0Bca?+gRb_WvMy`}ic3j9$*S#-uRnjZlG*h!6K~=Sw8T?SXwU z84(Mr8tp2>j|GZ!;yQ6aqA?=$%f+MZjzq~8h%%WY=7OSn?yhYTiV~5d7$+zO$EA)u z4LG5E9vs|?f~@-wx#db)Ij$HHJ(Y)J!|_VclF#5+bNn*3BNSr(hT`*C94|M#WKRh0 zxZrYSsJR#~OQi(}AaQRoEM3RB+5%uGFMz=~G17SKsT)etl-aZwr z|A}}T%G(YA`ma9nzKHmX8WDm*$vkR=4w&Tn7ZLC(x@IKpt2RlaPPyl!NG{%tcOiSf zAMIY9we5#?RJ3c0@k;SiSr1l(eUvfEd!Za$$0+batd z8y2~kGnEJ?=i;pWSOFv1f1hAx8~v@m!*IK^o}wZ?qvjj^e5hcRY^$5SR* zSB~rIIOox@G0ws`@VQ61q5bb|`RMJ-%aS67Ltw7GoVW!I1ngeO90-F0d2cRD%>s(h zdjOnA)XzFnrvRwQDyAcM@G6C}<^c5gn?QyA$ogxo1i{Zf*FzgAl={(cKLdd65nn(% zy^p`0ajw2#zKRibTkGvw1P{(qkX_}XLjj06X6J~${lfmazyCrYGy zDRL3glP4An=AmpnWa{G3t01s9T1LtP8TtnXJ9>Ff4B09lFR+h~gePzh8l zdKG|-psZ{HUFvV-Ss^F^br_2`$A@-`fto>e&$7h2*DWAZsilmFyQLLA>4 zF`PZ0A%>&D87~aA$jNE6gJ~?MJZp77463jy27K9;>UYOhzZ>U&&&#u)9`i-plKLt# z1{*{<%)^?5-iGs^ShlRY4@Rv3F7F)&`ds$bOvyht}7i5 z8$akepT{xqprGx4Y5&~ce^1=U@^&)-e=Z^(j|k0WdL-(5t~sbx@?IjoqcA!rRYc*J z1#hey&YM(OF60tJ9d<7!kLXl@h9SjtWGNV%wX+tZxbdM=2Ti_1;p%x%PTxB(5i{DA z^`#<84iI%#T}13ay0Y&X^B^EF05A}xWL~s^4L>1&i{UvXTL3GQ)zlfCWrEM zz6B?{&$3r_!9h9=EfhNK)8)Ax>{dVItkX`AQKPK#Z|Vo1T{?7k$felj7(iO=V@G>a zx3Ue~dP^J0z{vs+2mHzE@~n(31O(0=R$8WEeBF@~NW|!m1fT{Cvi|zr^mEpo!AITO z?Yi?OvvT_Q0Eqb6vAqZV_eg% zO*scl3&>gvI%%|n;LWMfiK^oqQL%cc;&bdn$G9S?)A-++4p%K`QZB^zU5<=zgISck)f|e5=ws?GRTzK zN|tke^M7$t?Qr~;m-n=G#u2}$dkT!&tP8XS z>+jpo-_NxGZyy8D_MeRX^pWATiD(`D!YK&i&4Eg1iN%6W$&!YXgBD=uSIj`TAei$Z z=$MY1wMq3Hwa{9}Gq<}#xcvPDY4i)Quzom}YF`{^JQvbsbeWMgoJ@g%lWuv*ev;~* z=F*P+%->w?pNi^WO!90{b{Tt%irJQ8H_Ta!^QY`^4vy#73sq7MR?LF~5mWx6NRKx0 zG{by8_XW3(!%B|+=1v!(R}Hw8V_D3@?I&&`|F^3=_vy=TM)So+1%4Y~obtnmuG2A* zC5jjl1)uk6n%O+p_)7NOa*+y@k}f_-D3vftA@ zKz7>xvFO`0Ra84z4sK+{z3^U9btpelwm%N?_8m>q;N;f6mP`%odPSzJ@&JveP(;nWl9hM&_%CV}srL)U_Fohbs2JJSm#A36Rp z#;N*g0BK?f&^LdPiW@@7`DMK@u+mOYsJ&fs{x@6w-oEm=PsMPCeU|^4=L6K&=ZttQ zNLj7V$x?RmG~~ZsR`_Rrf1K}6pv%X>VX_US42M(BG~5QKC%?`cb~2UK*@v=L};ggWh-#|~XK`ztYJV&!6nj)nDat? zi`x6j=wDD^wN(Yo5jCT_Y)|%s=gNDuDh{*{utVo4X^*D;&im-tvMnNj=-(7o(%EE{ zz9_Hh*j1#0`6<9t7nv~c_MJP|W4t(bWmL_%3$O!7VEr{-7IE+wT+{`%_T| z=F98hS^@TLqV^zDC5xn5VLVg*Q(pG%vfBS9@_&2FXTLS#)+0JsN$oPabc#G8CMO_r zvdUirs5^TYF^=@@`gT9n8Kf`ioVZ4)o=NbDXJ)-C_DO=345*AnZ~$^0o5qwG&myUU z{xdx+dTrw;fE@kdcnd&B-T6Q3tUx23d#12YI_C*m$Oudzt!e!X&w3^zemL$! zx$gj=|LP+x`u4MG=nOr|=}jjUh>UD&p$i;iA!j&OB5Dq(ujss7Jnog~w4)6cy(S27 zx~J-Ch_cxSpx+coIqv|Gv$P*mLnwtU2_3v$y577`f#H114i+JPE9wnTs%o>~h1un|@!B zZmpy-=$;o9XR<6GF5EP#HJyY(-k8!QRr(kmO zARwf0qUl02=9zpzJwnU+80C^#h!{!`p$?}(VQ8#L4_7zPZ%66AD6Gzbha=0f-5q_X zu{e#bf^^Ju&O%q2Gi5HYC^9(eOhtIk(CNHw*F2r4LR=B+JA6)bU*M#vQ)b(8jtLI? zVt%vGDOc2zCp;eY)EH*pV|LC=JxO|l00-B*5#e}nr-?8IFhtp&+UI}sR=;khmEx~^t z#l5*5N&PSYK)Y2eUJtQZ3}XDPnAt|rkI;JDvActv^bqYXHgy49>Td=m9G|1?2WX;i zBcoZrk1^$1B#yPrLHeQXkM7U?gJEwoQMuoM=)+@|YdGk5AjpPh!|tNUXJ5vllWQ{}G#G*(oT03@QsllIFO*N8%U ze}QD3R_0NcF68;_iejLE#Z>Hwf)5b3n~}&m*$+muStlt|0;^T@__!W8^>USIW$ti`9os^Y3cW!9^2cdlH$09!TUd&s{4|HNm zbwQ3+Cz5V(D6%^xQ0GN%5(IR}d0(w#BG4}%M@0NP`!oN;$FHBDH(u^D1JM8D=&emgn5*d-sYQ%Q7Ye)$XH>=&s8kkL^f7s) ziE<)K0Z8K-AlWRUq=Rv#t_aSzb3q+xV^yUrGax=kkqbq!)RP zK)wyI>I{_w_L;zfk%8?n_@sV1#~%nc@#S5$89xtYjOOG6^PNs z+A-FyqV@l1n&P)m?i&E;Uw-sX^zA2Q5{qtzpR9y?{sh928Ajzy^pcs{5}5U%~rn$t$glNW~@ynyinH3eAc#S3kGN(BM$n1RWBVl zf*S@{46dk01T2>?;RHRPAV@bkuRzTltOsu#8!nK}_VRp;`rVZ+r5$IqPur2#lE&xf z`AqKQlHe-rdn>dzZ6EU~IsZ@wa8#@ZwUJGxZ!2bckvhKg<4^D3@jrcFZrqzI_m2VC zPk$y_f6t&-EI&>`i5S9P4ikvZjX3LYia!OwX&wp!BgakwvO0tvXzEZq5UB|6QP?+9 ze6rv%+tbmOG#CH`6c$0N_FNhyUc)s6?r_;&I{j(~oY?Abf`e=?QMQ0QC~kgEW=BAb z87dt*Hvj-207*naR1QWUObZB8vB=9sj7Uq)4X1+p_yoWn0U1C~j-gYATvw3o=QWZy z&hdiLPuacM>i1xl=e{kX(SDc-EuH6X9u%^A1qh?`kK6icf#31JM$w^;Bd_5R==qzB z9}D$Xa@4?v_bPT4$vCy`Pp3kT!nFj!dRa@#gk#D}8wpI#5p8!z_-0Q4`v=h4{qKU)hd#3164 zh4dI9)2PRoFsF(<3N7K;hx1}2#%M2}lSQ`nz`1yoTsbs6i*z69tq45d`bprj%u~g< zpKJx4VD@J}T^N(=P-g@4A?*pYH4!$bZ=VA?cwaexos+B+&5QvlgVbVf8vsPsBXePL zv{Gg_e%U@o8f0^b#Ip@ts93rG>>mx#rfDcKsCf;dCAPV<|L4)(n-l&X-15XnBA&dh zbD2dzG8w(&x^i;I=WQQAAwO;Vk>fwo03EtjHsUm#5E%sqKbH&Phyy^gkM_}Cu9yY^ zERb2Q*r!f%tFo|@XusQXarz6OJ*5O4oI{W3KiD5xfN4R6_>6tn+O%(EYn?e{eHFkm zFoeza07qD#Y&+UrbJ@qUFZKTU{h9yKjOv>!_lE)4w;zj$C&somOfo5SMna6<9MQ@v zHX-o!JVyNa9IU=|=Prss1RcXTXIse(sAHp(Pr*Ptx(R7HP@u>a z{m3XdpTiZTr28CfNxv8C3cMsO75&n_JbqJ9cN=Zx%K05*#pl+X#Aqu)03&S{0h0hT zx!6kR&lvMrhR9wfL@G7wBui0qv}t>CJCvssnzz9Wa)Bt!&+R88zVM|R=l>v=&wXlM zUy`yStX9g6*`~f#nzJ8Ka=y~H_fs?h;zu14SRi^%P%2R~KZ6Vl`vmarK^eh-FW7dQ zM`kih+S=(|8F18O2@@#z`hx;A=G^|R@^W}kE*m#Y=^P403JvkWY{s4g1f2I~Lu){QTPR}q`mL(^}U#mzb!~AoB zgR9{@x4UC7SEzlo84dzYyO8f`2&tn>q0P-P)8Vdw44rK{j9OiOhESaKKCVe9r*`z~ zd=<5&%qdE?SLi9ARKB#PH*==uuu{=IrIGdXs9}$=owdk1>%4QhJ@p%A(e;&h@O4kuP#pi3(wyy_( zLa1HXD;jJ8#2Fxf9`9SUc8+$~xlYDR*1*u=9YIEIy=BxK!(Oo-%A^E1#%j^=mCZ^J zg1)AsqJ0ve?hd!9J!DXjV8GKV!i>5dNZ-L@yW7xd0S2@g#>A$5C-BbzC;M17>uR)_ zOfZ8w0c+>+`NRTX+-Zv=Y&QVyX!*~diF;7)IRNOt{Qhr^h<`Iwe_)GS!`5jos#CYz@=dxv(D@v!5#xh zIjdw5An}QgaBTF6o231j^}5O17UnJGeEmV}51c z@_t*0l}?!dlPWEP32;_zY-22{P8d{2@&pbk!?*9;IRAH3dEa{?KJ~GoOI&lpR3Ep> zx;}L*_-kFG{&J?sH&b@%nusB%p-!EkRnDuRgG>Or571wrcF}Hm3#fr?*IoWFaN+&g zep$7@X`idDEC4VaeIOerV-z+=)zEP*83Wof1{D#|>D1>8rnvS3{VcGTfe;S+%KFmp zO@(ubYcBJ77196X;-)uL?kNE1zw!-N5%H75;K5nS!U;HB3P&nXISfXTM0`YNn@%(* z#Xrl*-{9!vjP{&Vgd_yObP<=x*txMPFsSsP4}}CY?RIJLyj3Zxjs`r$(oUMls@LyY#Vfx;G6yO z{C>2H-Qw0tw#h+yMdAtg85m_yu?>5hsg}C84_hNSe+B>yjP{Y5=J;}c+;6dTj$h8* z=}+z7{=Zyv!|N;emI2uIKNfxaXmwhmTbsaiauFIKa;5Bwa+;zw8*EbBsTf46T(ok& zH3Uzb%|bZE5gDB)j@Zz!)N@U`VnVFP^LV%i(WtkL!c&4}=$KBZb`)4UC+f)&EF$CU zjLP|zDntafCLy|Z1)+o;FEg14Px&oOZdmEa-A zkA`Q(U{%il#`(YN%6EP$D)J-aNcN5lB~qFFaG;&`jRB4?ZX<)(y4rwBIWQfXI!acd zAq_y?it!n3O;P)>AqfJ|m&^U;IH?oW2M6v9k_bvx^~Gp!*;@cAv18PKXA>H>DMJ69 zwae#88sjMP;<{|Fui1#1eWd*?yAWe6C7tUw1VD_oJo+ai`X7$BQ0^@N*tegV4l`kA zxLB;N0lkvQvgmCHP+tifh0}!BX>Ui94o(Dh9>0lH%Q;{IWt$@qVu69AijWH6wmH&A zg5GnYkKdAm2V!TQOO&yVwp3aGeG>p zb1Q9NJNH4&kpit}nS6w`y}-iN;u&U;#aQF{RoA9%tu|mh>cu`c&i`FmKJ!fxkG@BB zPoitHC&%3P-j0{|T>fu|EH;ASk~2#|=C(iDn+WKQ>8ZEHSveM2|I|ZtpAI*!dk%E2 zY!w|N>LjZWG3>x9_fNZ8DGdfVp?WUqSf)`u=-cP#MO@O)K0dTX?t5~v`Q^gVUdjS( zkwpGS`)gaZF&1Q;1=Nsbd~1sV?->B-zw*9sjEK)K9ehm`_K!0XByz5MpafE1*TE>n zWYJqXR?m-3=nC!2&-tua2z^mZ1Q#)ciaDaH)1I5{PiI=+LBWe|I!e-8OpO%lV2F0L z2BRER({`6Ib|BF}eH_Dd{$Lt7_F4Bt+bzJyw5B2u?I4oLdMib=={VKB1Ya~>P+rdg zG7^x;h+t%!PaqQzl>Nh)`vOd|7ioMX-6Uy9807cu%!#&O`Fm;02xI21jqyn(r34M z=u_D!oI_AF|7keW95aH8>Jn^IK9|l~#^Y$492#`Ak+y^j_pA zmwW#B{`CL)c)ZDS?-+o#|5WsCTQ{_atkjH9fz9H3YZ)ab@{?_U5$Ys1eQvgc$Wf3e z5Vjyww#gzwMyeI%5hNs9#TGm|;wAbtr^UJ@>UCg{P7VT1#g{b}W{f04J=&B2gW!NG zfol&eo|)~>IyojX-Zt;)OdWs`+|c=_6OO=Qm_Ft;0_;VC87%h8`-Jjqb02&RcYIIK zQ%a3CR#Zcov+M5MxuN|Zmhy>j90t7Ju!s2{ zc=`x#`Qup9F&9hYL5bT5+9?D~Vy6gb`oS>3Zq3Z%BhmVwh&Nf9(;vfj+YiKYcco~n}#9v>RW*Q44cu@BlVe3Yo@cGk})&a@_^N01FMTFbY{Pc6sf-MLhBN&4Ry&rM&o3 z#Fu^};_I)(MJ{bEK!nKM>OB8(>Yv+`w4Knsazu3O41}uItYp-~z`-44>Z$}YF>J`R zKfdYx>?fUP>F7!Iq|yl1A7>^?ui-!&m{mID-u-zOX1=>p3}f{F(6#yw&QzY$)ee*n zQm&a+B|8R&jrOMPdgcdDz?0||_W!y?$&wa8P*OH))5V3NqX0l07uKD?E!)HED)8)M zyxral)8Xd;z)s(NWY|KEBYh6-huD*VEj@nDey%`#Hv0B`@g~YW0s!qNq=6+w6e@FU zg>Y&7p%9gC&g=)1Kp5Oii;kPlWXT#B!P(gr*VDpTHyqDB^UF(W}(tDUoGa{{k1-}w-j(tVW59W#5z2aQKN<1!U%YYt4?}tJrHC*6 z<|Xnkunjqft-4VDnhM}K|HIjDA-2x-JMBUQn#z2rX9a6KukM`7A2S;?`lsKs{X@|8-7W&bsH0>`_G`V}jDR@aKWIfhD5yc!6 z7?a(`l>JG%PjYumEqMD=xgirNqu1WkVwMZ5)B9HY{GY#Z{trWW@ui68|L#HihsqJ1 z4`d{%{}J)K9GQG^&B;nnu9HExG1obFwvT^LHYcCQH!N-8(Ii)JM;cKQeW2i0lfP{3sw8z;7_!<9v#X#(+hpEkObG7nS?&~6S9azNg zMsFY8{?X^}#v3SaodM|E=c8}$t9{tj;2xqJT)5>_dLF|_`h3b81)MDg`0|B1w5*HH zr|wT?0?s$1Qja{@Mlu5wzA55fI3Pd$DeI^<8QLVe%fIF9a9=#<(JjU(=YY|&KU*S> zqb<3bw%b^hfFFIRLy}HtH|8n@x`Z{2hb_>P`c9ZXIQ6{7sYGE&PIDV2#ln8t@xa_x zDGP@BSfD(&!7AJ)%hdcMAj~4IuEqk(vH*q?D`oU zNHbl}Yk6jXJ3lVSUB!aFu#dr%T;GO`-;VxC02aeegBBifQTQTau}piTxiodO4QyKIafFO`-p=5UJ4HZ0wd5`0iF(ffv9+I!-Pob z0}KcX?s#tKXX22yA@Gb^CCW6!Es+KUL@2X9oa{s^#yIIuz)2dT?9-9U^0f-7tXx^_}wf@O!}Wk{$6C}H1wey800fg_+uo==$kql5&FWJ zHp{B8hz6=9*l}A3dz!1!)83RrYr`S$r?H-h0ZjDatmxRe#w!AAhje$5uOx#x$ej3!Fu(w zfPJsO^j~@3`=f8a6A|wfDu#|x(43(oS`n}MAHXa>gJ)O1SeLdP*J0Eg9Oj%Z`gPbew`rxxxQAN#X(|Zb79oOHi z_J4RvF8F)p>zDsm`?@FKev!fMj!A46gPe4vr}2Ie%KQ|Bm@T5+6hcovJo6f+4`dgcjLZKUy*;)4qtCw_ud}>02B2?$EZY9wVZh;-w!0D0 z#%fJE|Lo92g+vIw-M0}DX8o5QAHV#6GulO)0iorja`k5+I5>|&uc)8(=JQyH>WQ{} zvMXKEl#Q2(XcyMNiEVo0W?j0?}J&Cx07~z8Bf}-leHt~)7a0+Mqzw~ zy;=DYw6zLkArM&PGfEBtpf1ekM|0fhS5!` z1}o&-E$ABmy};Pq6pI%`1H$nv%5t%U_2~nvNxKji?v-WE!9o$kz2H zi_7mqZE5~5ELZ~cq=FYw{pCh;Ec`CPuIVpr3;{y2wh{pK@pCeYpFtP>LPhsixPe#{SV0j)LDWhr&YBDI5=b2t}oCs1@k$m-S({W?4K)aatNstvf7i?Pu01L)H z$3{{)r#F-aXs>^+(tra|(-|;gc4OcAvBDI&98r+K zeN-^eQ%l+j6qh6L>!_QI2ssI!!=fOjQ_h1oM5(LcL&+xk3F9o$c4+^}AY_|!ta63! z)Xu=QJ!F@Kv}k(L>q;~*lZ1bM1s85#DcX+CZ>hD(YQ1~7A#B1e=f2W1$W*5{WQ z2@BFk-yF@~e&WXYKP=@dFGW26TM;k6tSKbQ-62o?up{l**4BM}lTiIfk%M}FaYgekBCRhDO8jhv3{c;jcqO{3mr(IPwXcP_nz|vs00MQlG~$M z5e$4@^>5k^--(d-kg}t~aJ9SBTB%Hx%h?q)5e;(}gSXeCUujmUYDR4W4u+(~K%gG3naCpj1G=pgPRUa#7t*)xp~NT@VRb>0hIk%+zRXrwCBCGZYY_+z8J&fe%S#AP@a8rRxW=}jAC5` z0JQ1n9%M4A=6-%>JIbbJU}?dt1dqq}VowN;AR}Z-X=8T;x*Rk2NoB)(MBhFXt^d(@ zo#ib6fIg^(60n0_Ne9M-3xz@%MeZtKLaS$oAgDRm+;3Vr^=(`)LInrnQDB7S$(pt+ zVjJNl>1bor!+T|oP&xvOj6EV02Vjnj(P8O)>QPtS zVBH=VrQMaaIDOdijOv*l^dPFz1!?cFfH>lCYzP=x7pZayOwu>lc+|Z)o;+|TW?L_* zNb@m#o&y5{qsIZ2c_BCZ@qfgvXH+%yFwG8UY$F_+6`ga4g*S=&f z{jYxT!_oTh#D2OpQ6cFpYPD<|4W+~9fMsVGwN2DEoKL0zxFVOdNY=HAmYx1#B%0ro zN|V8k@jPi)pnryNg+}Z4d^+^wT0aybKPOF8PV`v(alWNTh0@8geNGML=hfb>pB0+{ zBwPx8);>#g5#xRyU?lxYgJup{QVZ5*ua6PN1b~iJFV$kucZF6!xzzSJkNu^ozO%^WVNLAXg?*+Wg24)~2RC@ORG}@fc<0^UqGZw6@ z(l?wlGSEx5A=dZNCwPEaV~jZvn}pAn>5z!%?Vm-&6Wc%j;on;~W|TL{0JI;A=(iLF zdUORxmlOx{V@s5mzZ(YAjJ4#?LnpF&g)~6;1a8^}USH0ilVX39QuD{Xb%wKD7ZJ!jF@8w zc{L6xS63L5(6&t5jcTA!KcXRt5JLM);PKc;Z=C4gj?M$Hd8H=O$-^fz3!w zq9}Aq1T+PN2$aHyiDG@EaH}4)hYCU^DB(VhYnM?X>+R-H4o6hExZ`>r9Fo`RSn3!$ zUB`%mxjb>Eo23^}7$tkosrnAXLYhzJ*XwSoB*J1~UZ<@{*)9WN)n6k0ymoO6vjdVG zXQy7%C~)gc*6;Kv7c4n7S^(uL;_)YM4*7Xl%8M^ua{gbxt2u6$|8BY;E!uYX0tBcZ z-Hbg0n$|%d4=_>BT+!3{e=%>c2L%R@Hx|2uXL2F7ygvNU4IV0!RCM%V&sb=0X-8Lj zGu@Z%=S#q1uoxT*0;#IQ12_w^{Lr2T`&ILo&%7GcSWfZPi>B?=Q4#e_Z2A-DS^GPc@ z#aO@xK&JqPgNbWWK4d=f^T(gOiTody^5R!7$NPTm9Ft8+!`)Ap9RKN+=;vFKs}pYo6jNQ;a1M+JSLWXbR@cRpbMFw{`{QGt=K<~B^@U0LF4i81{x|t2f#|kGslJL z%A7Ni9{@_J&*qz}`lr6aZ)7R}-#{S0rx@JTqQ=N^dom)Pi`OV`0syqnm%|_fK&Q;y zF@>NU&my`OzyMSv=oyY$90^v8r_)|VsCD);qr+{~MT)1KojLkdbe#R~sGrxC0xuLI z1&-;5jMxPjWId@^D}WP2h#1vo{UyMMldilL#}|=%I6d0WFw*g`RkBn00*Hv1@=N8sESlwD zmjmvDqUHh)$rYV+&*$bwMO z#$;V{Eewnc_2j%Moaf=psK7bSPAyU3(||~he;;+rp&I~EG!6tdbfh#C+iv;&MU*fV zj7Nb@j2Dez&pFRNqYnfKGN#;?dHk*y``lTy=cc@?ioen~<+tQ?IPyz=f5=Au-I+Uf z2h#cgw+<{4@Ji(0ha9IaTb28$gFa&lJk5@i!IZi%j`fy53pD93XhS$> z(_usy>rR=;u?4GIb8A3|K&m&k?==Cy%EDe-`Y(O>T@3goc*7G3DK_$q!-pt<=e(eUE%drKkBf^?6mN*pZlX^=@Oy$nlzN zH+5B3`16`w$4JJM_j~#nHru!9acq2R;pDtAC)GaGvl{26e4*`i?`C%Jnl$acog>=q z?W;fZPv(am-MH?HWoi9K^d$-@7-@vL$PbZM-H!+@tdoW4(f}XILL})?w+~iD;?Np@NwZ84H3X;$+F;60qo1iwM0yDaH;8H8qIgV$n96m*yADR0m0=01H6`4Z; z(?De*o4|Z{{$IZnysS`v&KHcO?Ay0FV_Sv#OJhiM+g0c}mXarJIvz!#_0bO+G`5wp zw(Xb5{|jHbasCfOq4O8IA5n7~ukgKmcC7I4+hy@zr3k|uRE!9&oDzOBB}UyU=M23e zjn5Qa+Q!nmKsyplbd}|Zke8h0=d@cZ8k-S2>tV`bS}%h(WnB^|wPDxkLnMG9bCBOz z)Xx7IbTJj%`1b*ju6q@7`lPaP*g8!HCfl1^E^BuaSmzwe{g%xE9d-7}=77wsfG8|p z?2s|GDcR0B^?ZuxKOC=8-WUMbw;$0+D+ec?hY4EcaM<{CEM}xH_0Ncoj;%VM&oT$^ z?cv_cY;OrU^F37ooJ1UTFjt)IN~Z%QU7fNQp+Z!Owy6-&L6uM@a^A+iRZ`xh#8ym) ze8zsY%h}-@jEgv4jwiS30F0$#3sB&^aQrNl69Hpk28Mm+bp?>FVp6J{qt$7GrBF;u zH61%Tft2gsj@ACp-{kxshEgx~Io^_5Ksy2CMOOL|@paMle!S=hjw}nAH>O1MJNYb) zHP_W_BgjZOWImgbU&PSstdIIsC*d)@fcrdxuc+JZDJMk`fNSzOWw%m~@;TYIWG49e z@!fPN4`2A!=ejUQVl&91Ok;#TPpl=|YtO0lP76r~=D2yF#lJH$uhcE%h3VXUF9F-a zwx{E-`p&v2I${Qj1$+z$w*DjWDy6;Jmn{94K717szZ<=MNapN&>x`*RY-`J)djBw7rHQII@= zs~o#2DBH&fhX~ba`hgVJV^z_CMb}Q%r(oB@o_$=)@e@kal`xgaz9%I(jV0=E%FhlNtQY-QG#9b3JKchS z(E2*`#vNyb=??70^qsYv^fq-jUlJxxf1!Iaj&NEP^a1!YwL95<>LCHyVYi%xBvYF9 z{|p-|w!?r|Z;RuJ59fKlHBL+PF%%{-ZG8GIdtsXN17nn9EypQmFts=-b+Y3z=73w@ zY2O(DX*&J?iv9GB+e?3#LOd>AeYh;Ge_!mU4-J9nqp^;Tg{lGqB8A3*;)_}8t!%kC z%n)$aMXD$Q(V=0QoJ6OEM3G|+He^L-jJ70b@s+H-js2#*MQw{=ySbId9Z_DB%I<=& z?U(3Xj0O5tC;z!Kh4W69g{N)_LPvd6dC3Idxn?R$b^6@$KJU1?8fXi6I zaFH5ixlW1v8d912cH{gXrc#Ie91H$R2a>Ub(T03CU$YV^vmVVIHi9|r$sweT1nWQs z&{7ck+@qWynNy-=|KRY-9=FBw$(S9_egLpyUNO!kME+yJR2C$r}IY}|{ zu5h8((OHn%q2i?QaH0ym!w6)X#aThHvfc<)XpRFxMZL|+CX)*XJ5Z_&QTKZ)N|8VTv?s?O~vQE`)L3(MheZwooNIzO8LSNIq!QL2A8`OcFcA4 zVG*g3I;G;be&qaboc}{nzVcGU7x7{r8tY~+?m6VJwIR#B{twuhYk((HN0%GMeGc>m zu)^7SOz8n2sx9)?F)5PoIXH9f-SC`k1+59Kt?vw zZ5Qog<=}JNX@|_-jLxxRu%iK6+Y}5f=KySNZfl=_SHNDUQzs}m{yyJ=4QwGG%!9Lz zad&?cf%FCY+9Trn?SQWz07M&=kmBN5(kX=H$S{%Rh!-m>r&svOb4MNdKLsNvzi1hY zxaIpCcmR-y;pCk1$={;H==*e>Z5z+1ev=MgIjLN)kP6Yq_|{kl+R#w%QECelK@>m@ z$GV1<2ILr%(<@Lwj2q{SeX4PSQIu_eG@xC_iDT<~ktf3&L(bA*trlXI!G+=q{n&>= ziU7;hA|89{=6K(SrM!61{$GD($Wy*@_mJ;48QdY87w3PztOY2;oj}xWhv{{r{=#`> zluJ;+!?y?`cnzHeos>8?=q&9NgRcnK!x*-kXXfYU612cMJHV-XlC?wtk)S~W8Mi;E z)6Xc$!U#8FUhCrwP3uu-?5CZO#e#wMcpp%I`muZ=5&gjV*72-e?P0OYeSBWv33KM4 zu;hAWaw!;GLD4W2mB%4sC;GQxT{=G#{uERP zM%gb?XheR3lxRA+25l7BR-p}!ZpKnBB2Sd(CCgvAGT4S}s`&u&{f423Y{=tOq&_1URn@ zmVANUYF(DkW33A~&vuzC1MAOcIQZkdiXVuW4n5gt#ArwV|BJw)c5iy*#AXzRlIofl^%1GwKqTyvRG4kJ} z>xrx*V$k%6IG2LEjY(_}R#DnMzGu2bqG}V4q`PGvEJCO;1EARMj83F30q-vNhX2Mm$_%XUHc|XgSX2K@d<_ytMuvabM|aKHC9I3_X#x zIe1>B8o-P)z{>SPT(d-7tn?^87v>H&GoO)%`S8HC+7g-e6JzA#OV+ZFIpLg*-wk%0 zAmJ*;@$ClqJPll+=&bFS7A?ZgbM2Ja{Cvh1wJt>7Y?^H+J5Jjh7$fU5cWc$SBM6#U zSK8JV5$!74{@t-CuM+@lKd84aQ1c4K!46SC~ z+uuSZk$FUIa}m_Z(uUIb$zG(I`i2}f6eh<>4jqcoPp8AHUUR5C54-x&h5`_|PJbt| zhazShaHXMPgp#u_boBYVDBC_?)ArEgTUqk@l%Y2Mzd76g z;VG-L{XNJK`ZPw4zy3eRRA>trc{coizIDjC>p}(<(3v_UEQXG60M1VDW*hRE1m1Ki z6%;`)XkYvsd$&FLn~}PXj@bq1l{0%h=DV5BG2QB8Lt4GYFb0rk@WS@fab^HiwuAKl z(Qd9e<9291Gv~L~3H8xWI%KhPhn-FTMw_2=BEb~EOM)`8(%Dynj8$M%0VaIGoQnOy z#iYJY0MPaym?`0-@w-LcM8nnit^*Ml=<7KVHDTEwN2(MoI^1b}L=1K$`$@wj5d*;k4GZa~7IF+S zODzV(f*0n?L@A+6Y>z}_kR7K^X*_d2lieZeEdXQlRmuUtPzRzM%JleCHw*qAmh$3P zBfj|eB7X0zvdyRyGFV6Y!nt3le>i3?GK)~TcjsD%$1teK>u5(ma< z$bN!~3FzSYzNdXO9>3vWobk-h%h4RV#hm~I=?1oDhYz~HQUK`>I_e16DZ6i4CuMek z3<$@?4-|72f#YBD6LY;4^pz71oy z4Ih$qug)iNyY7J#Bk__u^moaGGeF+eM^X+a5D1`EOzt-7=GfN!h1c;JdZFKUrI$$Cu@1Je(iH4p zL;H@oThW(%*;CDpqx6n;_0iT;$Z9L*yp)t<8Wq-vW9`V>mZ;tnUSjtxp+W+O3 z;dG#L`Trp|eZPvnUlsM=OP-EGXPNis>T1>{EPw?XZm+UT=eg4c7`UesN+2wD0{}YZ zzS3YZ+Ou-TLPBz`5kTHf$bVpiD3~Yc$-JRE( zt&_ULY}$6s@21b?b5@afIrHp0K_*xO)+hIcjs*iQ3AzlNRzEo&JtE@6(YL2>l1Qoj8JS8AUM>V%l!Yu zcY^OazcnV1i(Cg0p+<;crs#U62twzp+6bj0WjjSNFgp4B_RVVlho`8>8uf~CzsAvt z^KS!a^ScUwV7H2S1K1Fol)8%g1#rREd-|5C$#nL0mPZSX!YyEEJ;t%gfjVPcN885( zg3|uc{^Ye{A2Ao%XR*6Tf#fyxY2vp6^NR5*|ADq~+*WL^r#n38Vyc16k=w}aJf#6% z8vyL5A1EP^ieZ?ENImMNbBh>G!znBxruy8JU~1dpz==*>DWn}%cf=u7Hc@_LV?$d@ zX|maH))8oLDlYetT3@)UAz~cTLt2byOFK=LWr{3Xh~Xem5uwLIL?UJ>5U{MPq99{S z%Ha~F!vGZcf`W))d==yCbmycvM_comG_u?I67k>U{2z|;;#V)u|K*osII4i2M3ihD zdfE3=?fq|*r{3^--w4%n7+wMDv?Wc~LGis4&iL{3Dr^g*d&>XHeo+6~sJ9%~uI=TU zTkC%6YYVY6+2_2L(P#}7qixoALVlaUB7Fr|y|lAD5w`l`Oio0-vfa9XK2 zbN=%F^@jB}ke`V0eYArCAb>`6Oh;W?x|o6rO@T!WIb)PgyVddk2F8MEKRORd)1~vc zJK8IDrj7bp4^xq3Yh~?Fs4ZAI4q3Gy!)fw3aR(esLAq1RI%=$&jzf-F+Tj9XIfuu5 z`7SC?`y{)N|8xC?_gB~vfUUY0?Jv97MsP?L!BahCyU9XGe|EKlIW8MMmqpJF^E>2v z)bBp5#`=vi0PT~pU)`AoTCW+-D<71l&Yj0+g3@8tlKBhA9CD@6B9H3%ppBXSr`zT&vI%L#u!_xw%H|6wRE zz7+BNZ(W=}opJ%yi_YoW{2CVe9fcHkF@&Um8^A?B(q3T>*R~9nj!_?D=)5oXqdXUb znE`>l0+uXhF3!zoR#4$g1+I}a9fFXoxBlL=4$r4oAb?{`cxqamPZ=C@@!33cX9yOV)qz~T1JnKRn%j402yLJkc}Kv2%9>6ld22xzE_E2fTtlHZ%u zzto5OB0^X~26&8yr$7kxoUg`w<#mkWIaXEN!G@+ixl1eW_kfk-;H>EcVCbxE%!@5r zfB>ex9Xu3h#C%`cEG)pQ1Aw-Vi``+!L>lEl3S1nOqq;r@s3ry!S}_Y$VhBAKQ&j!9 z-q9EbYPEQc^odYgHlYm;yc8?&8x($)Y0(|fHH){YPC>jH&y3K+Ca6$Ve>#6doJ^Ta zASl~u$bVugOi`Ty$T1ifVeEPcDsJ{&MfR9)$`iNvu?<3_^~>?TU-;4u?f)>8jQqcL z9`=BN1Z+>|g4Lt6gB9wjOO!1EwQvxmTKSCBq5PraathXS*l80?%q9 z+Q$Act>eLogRf#FtxsYL(Prrju9Y{Sg66OBhHq@Rj-bHoR~lz5wSxk>sjZK zX4s%iN&O2_?8BKlnv4L_?+~`5EgZ|L$C!s4cSpyo2u0Q<#CTQ;5RF)!W3WZUZgrZm zVtXcbqlYM05Q!e(tEi|2(3ql%#WPbOyl)&M|GV{LpZ{OJasCfO$rJuwekCH{EF*^g z9Xi)xbUU1J=L+whaT{3x9&H+5j{o_t(LT{Ikxez+XjP0u`NM|4SI@M1bvB6=HN-yA_0Ky$N0Dg@_gxDrJ z--dakT_-Eu?S2m~WWM)fTuykr1uu~{kG{f!rhIOWT?dHr6jXzJrH`bY=9x3*eu{Mv z7GsOp_U{W6lf+T_FFyXBi1yE-Z>Q1NoXDp6o2+V@Oc?oUJdo0McMQOIvKe;UfaAdN3B}D?N-y6b}&sQK;9&c_-UL!vg zbg5KzEf2AAyOMn*V_NzGolRY$Zs!;>;E?XZo(T|N^egp|fugVtX3PCG!7@e|>uiUo z8oa*AwwR5ovA4*XK#AinzQ+L{#@Sz7#`ad*R>zfht&4uG+SF})60pIiP^Q`Tl&t|s z|G#McgWLb{{d>f#r2zW&bo6!-DvD8By;FXXla?Hn)M*2H(uQBVIu@X!oG1kmPL*th zqqzow?VLOeAd$1=`;PXCfVT1coVdz4WgQmjQW+?mo-%MjS#3)xB)jSTNjVYmMgSF} z?_xw_D5JdQIo5!R!oSLaxpKH0f##J>;$*It;dtOaf2c%WHcJ zS;Wle@s+;pYZeJ|=;RPA$$bD*q^L_z7hsD4LAQR%7}^+D{jf#3YeU9z-of?!*jb}8S5z>RD%4MGA}dcFr>ts5c#>J9ln!{trt@=l}A{Fl>E*>xdB0dC1Q= z;~#RJrMHXRa+gT4m@(`D-z8r%+lO6B8^FbUH14^l+}}|`!*7cgB^vc8(496@lo8`P zarkEY$T}9=2{6vooq%`zosLv=F&z=E_nmSyL4{3w{=p^(-;#*j_{_ z{rZ5OORxebQ`E58sq7!ez5*9KKTH0-AxNTAi>~Xba=TTo0qp<)AOJ~3K~yjMUt_kZ zZAd#Ji;&$89JV!2OeOI0t?8@aisMK>$Cou-E*Nd7KnL^7U{C^}1ZXL1U?K=;rJgpP zwVy`2eH=I!boi_%x15uW?yUg8t1JS{i}FDjq>VX`u9a6Kt_I>R;c7N;N5{p9LOJp? zq94-pR0duXn6%n@cMegUYO~RTHCaI9;+%R_EfbKrxzpV-HvE2?I0{w@y|EP8#_Bio zY8YoaOko8;{n41Dg3H1Z7zXw`8GxdjZ0y3g%)?_FPBJdXF}i+!le5o$0i>c9Nm+8!N@Qr~Zd*eHqi8d$A9s+6E5)c8t)9qBH+ zZP-gbkC8ZopVE`OHvWJ1-sRV}>^koobM0F!C5|MUb#E0JCK1WNi7bJPB_UEI*bM=J z0QUkUaQZT1|_jdigJG$U**u^wNq$k;S?$y^^RZReXsh^I=gURlK*(UNeog zzA=B}oAZ{Gy$BcIEamDA&#FXN5Jk$J2%#_a z{%+%#yuiK%cmQBwHD5*dINyw5Lj5l3D}bNg&M{+D#>hsyN9X&9Qv5jpKyROl68=63 z&>D#4Om((`J2@qR*g6PHnIN}7p}bax=lD>=G&Hh)Za_peYz)6ABSYDgb^*0c-fCFb z4+gcg8-hi;rY8WSnV#-G?iZ%f_yE~@c#R?XH1>LA5n1r`&9wRfv{JiuW_F>|IR*`( zxe~&HF=x5iPV}eH@jA+R>6M%De<&Ao{w+qHJ7pw7yw!WlrF{pRpQrk{6TsAEi5OC6 z_4+#v86${caz)wkuyQfX<^%+)aJ@(nA(>C(tb1%X^`_P9kvC1a&T$WrcsR!p6jPB<~_EzM0wB- zI)U!Mx=u|JqYc#U1fgoX^nA9%cDF1}5WtGs6X3~0!(FMIPhphitAFm5S0dt_Dcr=4 zOhRJ@lAd28%55le8p{%%0IHb=P@grMveo-+quRks2Ci&`W6_DM{}Rhtn;8YU*8cEb z0SFS*UKyz{V$6z(ZBN5Y;fVU3^}6AVh-pht?~{A}Y|o@z*+#2R?G@6U%7UWs`{EVm z(pH{Q1U&y+*#856J$>U?>i0wCJ@={)lD`Je_P#}H*K_ILf}ixAay=bgjjM0}^6#EP zDD~(vz_60HNfy*kc$!7`>9h4`YOeuxI#b@BbRMkU^m-X-IDw3BbDQOuUMV?tlBUCP zafc5yD#nryla)|lPWH18Jzx0Us(e0~0|AVzewEn4kLr`VSzA-h?lRCxlhK%)Q;VC^^(AUXy zMA-Q}m``CJ&($;RETZf-@NB!DiHK>YdkL-YAiRfYAh0FO*H9n9l*;J$m)qMojnHQz zXGjX$>ar|Od~{(~n=_vk=a!k+IEWrZNn4OsrT|3S6bt~Ca+z@HJ%u1WtwB(jp8w7G zf8ejDZ$^Cfe~x$;&IBi6LI3pL@=x;B(#wXDFM7-PHUd0px=sJndD-nb*HO@2EQ@)2 z{ZyE4d44#3Ca6Jt;GX2A@*Pkh1~&&Cba1Ceeu;l zUYz~Owl9U_q<@}AfbXI?(+(@Irzk9&xWl;x+^LKnPk@iQwND@*85_p`!yk$GwVUyO z5La&U^DHKhhv4z#kJk2abZ;EH{^f>0dXI7_UQQiz@?R>$J`4k8RWg&j3Zvn=gkG#0 z((KE1C|^1TMegPBC~6AuqxSl)s9fzpuhGGW>Y^-P&voP_N8HA9$Bn8Fyw{#4aPIQV>pA${^&c}%l{=}vq%@Y-Q2+|2|5_f z(b(A}Y1P5LI?$nmnoe;Wx z3P$%cgoOUO(K7{rsCN6E;*#!nuuUv7LAL$T_<>j2kDG+3?2I1*g%C!M&`LBZ-n|*g0=mxsOyOM3#H0=f*ho4E z2<5|7hk7K34g~>EMtVh8@mE;1*mPb5=ALY|jwxzHp0FrNXFo)vR&8A|?s!IZwg6}) zN#G#gyLnQK-4C0}b0Bss1zbk}+Wx6xM06@vK}-l?(A(vC*c9SKQQIJN#Q5%E?Ly3` zhBm#0hR=Eho;9Qih?R=}r2qYSz{iYcR%;aqlotXX#0z$kWvYlAQ7i|j+_|hs8jE%gXO!*?^Osg z-z^3=MC)O!30P2=)%JaUhK8S!4z`K)tUhO%Z46M!r!9cUG>C@HN>s%n25hq{k!;^S zPrmeeig2NxiIMuE9w?oN$dw|D?(q55Spg-o5RvqL^7yvN&qKMMz8Ue^e{zQXDF-kT zjAEVqAme|0hB0tCO+5hz#iL1{M|B{tvZ;J|#)*5W*#>t~^oZ-dR_kJ(dTMLTvR;6L z47^7<+b}ZhBMsl=%~NOg`Mv{l*M+1H`^-5#iw&GcKUd}M<9^N&x*%FZAmrnEw#B#U zb>pwP{$=*2J5MRXt@>@!IQLgc8wy*RdIUbJzor_XvX#uQF?#wV_Xev($s(j%gPNd03saO_Eza7dgL?B z@@B?>rCe940p{(sPQUg?=fOTV;sC6c{(rBKmN)TxYX9MQ%CIs{k?G!+1}&6@Wv}`P_nH{Ch;ihoiRz0ND1A zSHw#6DX$iYC@(XJZO0n?z&m32)3{QQ*yzHD5EMtFde(&{iNNsHNwgB4S6%!I^mVUg z=75+XEqN*k!V1lEDGbl4c7w<^=fIwHoIIKn5UIcJO(;s!3KO6|CNvB~5tw*86SzQF zR{bu0GsYzS2?SpL@NKsLL%B5J@4aV(kC_^pB0obB_7g1j{enq=@Wdi!8EYM(^QbcF zS8q`^&Y}0D4QC{?zTbX{65M&g_-J#CRBy!4m1&UoVKgaon*p8@qVMBf0)gTn88D$v zWy+Uy$Y-hB1scqmx!j%mee8oaDre>WHW7&Z=uta5DOrXcPhNCLjB-^k z`&%NS3;id#=DPbSqN%ak?Om0(h>~X85N&4UIqNv=>z|kbK*T48f~MjI zvT^T|AtM^na$YgeZC_OjWaLI+I(#Na*b}2t9 z@qK_8{^#J6R*Cq5!gDDg4SbCDrohrLLwM9@=AqKSq8$-2R$a6@UNqDtI)l247Z;V` za;s%dNC`ZPF=YtsrB}|SezysKANWhg|Ji%EX}}-OK`%=5@cO4Pd_?6leNROQ0}?Te zPEpuU_%y_PVBim)(2>5=M8AkA^ME31s5YYNC5C}hdHehs zc%jrOzRHIHcajg4)dpxU(LguAZG`ft6Hnc6vVAoEedsU;{+`yPu~R#It_5Yh7=E;e zP6zv(`nHbi_$+%+f!(Mh@VVD^tF2tiPTF{MgAQE+gNx}xyq9zjGoTJ!95}Yx_H+yh zGC<$rT#DYU{xAK9PF(R(+BWo6sTb0hbfvBxsn|sdp!H8AVVKiWxVkVb2i;JmX3b8X zAq}*cUd)HtkfT9^83`^J^5+q}**4V`!wB*uCbm8E<-7H(&EXXf{p)${hwms?v`{pp zw6)c?^h-=gn1b0a^ZI(OdoIc;#(N2r)IKs2ym~Tq?k)(xRA?JeDj4$;Iy3;0?;G<* zJIZ?bal}9P+%4??5H9ZR&+S^p3n$;Uc6j+cS$=cw==;H=Vt}J!x>c>j0Tlxa2tKh zl#4k=oDi8njKJ$reb3W34lwkaAEnF)g(4&+g~0JZq@V!67SZ-a1fXvpAE5=$liQ%E zQh9^!ooaw+82nukA|lE%3Nm6MTPTCRt+yrSz~=yLgIHv~3{UR^AaJse29t)K2$X{5 zA)mkiad7FSdoJEj+$)iQz<^qxNlP)t1inBIhN>JmkyfcJt$d~x3RBUL?0+ZBf(zp73!z_F=<_XRLVDb!<7;Pwe zJ4SnTFDEBnhNF{59Mce8#%FH!%FpiAkW)v0ri}qnUypDCYxn8!5r>Z7U z!81J8SG<-^lj+^~A0TNPw4)o9vBGc$dv7H=VcaNfBeYWhgBvS`wKeD@I2eMHTR3Q0 z7ur*vBg3L@(j|#}k3uPo7L|?@ZBVDigT|F@F3O1tz}iG$pt>mh`5MBn^e9{@k=Bfs zFoL4rUV0_s*M9xx`9Fk@lSP`J5S0`7ND~<1e4Tm~w9;?c z2DG1`VA4@$FI0~1N#~a+>V8b>E9qQb)Fb!ku$68v?Ls>|t=qjmJlk7caS#ps=lb3I z4tM)%ztR8PkLN^MOtDkO@CH<-}-+vQzO9aK#@~KK#f7T z#XZH$fmtf^k#$C z1{y5rpF*|OKhdUybl}Nw5>7r#TDGV{*oPtK5~E}k8(XGgNS^{~762~W)bZ@u-X~9P z#{Z#QugUnsgS^N1_mv_$d8416cOLnB6ju43Mmt)4hM`?~i}Dz?)NBG4@*vwNCj#;y zxr8hv?@MkS2ms7W7FR%nx{LBr9I*uuS3fnEC%51|>rhIP2RJQNimqHE8AcI&@ZKUGiAy& z1@v!5B*ZJ1*={Lfb`gLExbdj2>*bP~uzqQNN^v5$7b^hh?GyQ#hERplJB&asfkqag zmK9>-0Q;mpp`sG%!l3QO2*p5|(VLuJj8wfbc zBqq_W)$J++b_E0VrwLnU)oDzt_hG{gQG-L!yoQ#&K1J?y=n?SjuhD=j5 z%k_g^oJn73fN|~KPZ~$H0eXl3vwwX&HwJZ-MH;5=>%;kyF{SQP8<=7(Dd|2uZKh=1 zMx(u1KnZafy6tGcdk=cwLoe*R>TE_x3VIs(7Sq=S5jR@0Wf2&#O`PTE|jADlh1noJ&BZ+^Z}Vr^Sp#8v1=vUW2FA zqHdrSQ4B?xJQw+a!JEL(>Y}cN=Q%~&EaH|cd|K7n5d*-tduUVo0~)dXG$3&7_Jtl| zB$B#M0Hx&7t$dZsq=Am;=C99&}%fQ`^-5K-)iFx4PscH-sN#^z;09Lq}8a)3hWZSqMVG3xNJiYAP(-89O6j~+DF_R?E%^fiUTTD_mN z5PeP|AlRZ1aAtAR<*ZX7!jkI)ILe7y1S2t?DGKKlB940kMUF)bdOIzjJh|=g_fW3a zPS5{`cc(tsqpW`?fb3hecFo=VdT;07!ox0nx*~aadlHcJp>rJg6A21$_=W6HS1fJgSIinth#w96T|T(y((JW)pDAF zn{WP;{|qM)obW_Neb2BEcHwi7CZ(X?GwgI3Gg@;-KSFj|kGt|jn6{A6d7Zu8 zjQ>NrUVAg*^ETlxH-Fkpe;m9%miryN)2P@P@hf_!&4#IXe$YD+rUUR!mdW?oyszp@9t*S}xhq zpc{V6~M z2&Q-nC0B>RN-xS%B01jx`Nd>fL~rN;yx%qLDAg8B>R=^6FQzF>*+$%Q{txMr@z;^Q z5evPrCI4%ApEmj_d@*^sG{P~;=QhH{Bi>_p{q)RDm(i2sXE#={(nTuk{9Wg66i+VZ zcM0^}b5*&W@_iY(YU)wYa?q@eK6pNO0wo40_?gSB-8d7R&>*IBvT)+mK3zF)fHZL( zt8&2~theaG7`B9-ZfOf_K!VYgjy-i{zH8uM8C?KP_hMCe_Y`k<9n$;lXHA7RtdxQ0}E3dq$pThx|?{ zJEPCI88w|thXs0}c043!|I*6j2#>ydvrZ=i)HA z!EUvz2L=BbE12$UO3AvXA4ABp5Dg268RzDg7-WER%kDn;{_%fz)s9c+W^i?a_2l%*p!q(0bwdghFd5|kW;Rs%}?ILR@kFq{ZRho$cpY~6ev@uD5Kff>%B7OeT)tHdSf(;&X;JkS$h}b$FXLFhyCf> z=yCst|6z;Xo=3!^VIW|r^Lrna6I~F&(u<|BpMFCF1u%SD!z5fR>aanX@?gD|FQPIu z!tF9WETcgxC?11Z4w~q-on`yeiF5`PDdpQ#HfRk6n zukdaZcJ?_)en$HQU}Kb3(wgVwLbZR|?|rXc+oUH~28Z7X>Y-EokTdT`w%z)`lmmfI z%K28?$}qiO)KQ`@X-)k_ec+Ct1Gz#Q(~qvh5)5qE@1R#6;aF-_mh*;;`flHgj>q_< zF27XAG2daNQ!?Q5#5g9czAGo!PgqR)FmgZ}5tZ)c9za~Fv(>Q)MgaQuV-fAqK-EC< zx-!=!!*ULGVa^kI?pQJr7+@i%Bu>Eiqm#Lj&V~jXGdS9jpcb@bNRepW&$HI=gP!bL&@uR$xt)-WEuQ( zub+mGeJJ%)rXp~JYV~UMF8AWd_AKgQWHBO&r%tcZ^&I=N0c@0LCob&{R}5*Zw7uNf zW{{mAIY2P@Md*92F9{`3LZnGFy;b7SfET)( z7hS?(?2iOd)72u*VahjRJD4#X1 zm~2SplSDxTwB2d~NKcX>%v`VEGn$YF&i^=P!!Xq&Xrm0@sm zj3OdW79|j(F<}IOG%ny@^<2!5#TcssJ{IFc(B*VV8q-Npp1yH-{?D+deA@@>QR$^jMTlWq+j9=U$o<1m zd1T8&zB#{W8}DciN<@rMtDZB^o$}enGp-+`TnWBQh<^qC(f8CB1o@|o@8|p3zi$16 z7e{@;vUn&A75)@aiE6;J@0?$?kI(L0Ilx(^Ij9RJrzWc5>JCYtM3?_A> zY|D0y2<6IU^e*=Q(PI2y7u-|!ew6l+j?lRUFt|P0jAmyUyif2&dS;{-wsyaqJN}`Z zu;)d0&LiOJlhQ|koQQ~i0RV{p(lGvx+AzS}V5WqD+}rJ@B!q%`Z5(sZ1&Kl6iKyCH zRt}Q#*D(fS3Ohw5P+6Ks00R$Y6&aYo0ze2)WXilH$eU#bm=%bw^}(C_<)Ehnt=@)^ zaBPUmC3I9e%mp&al1kH-|EvH|0c-)m#yGzG__ogfpZ67%;?f{iTpec@VPtp z@NdyizUc&z4#;VMDRTkuJ@dDhnhTcf`)X!-$=e>W)6l`oJ3frEOY{v2yF@SbJ3)yV zf`}@AJG?l_54R5I+J8D(*?w~(R_#+)=y+va{UA}HIrSa%0}b*7IIIn2O!aynZ4c;% zP@grf+?Xw*c+15;Z1q2*vIIDJo&m;1=?EVuwyk*U(6UB@UjnB$Zv23B$+Mw9wHz1vPGNtol z>&I;W;&DUq76l}ou+@1*snlsWO~+)o+}nls>78*N3uo~&e>-|H>S`v(Wf`FZ0_d#N zfiy%YQ~k+fe*0+a^bY!yULfgv-rMf#JFYjo(LBddIuvvbjFr+OZY*g;=~ZhyX$*@Y zqHZ(2L)$T4IIG=l!zS66n2jFicJQL!E4F2exV$%hD_}v!_d4iAhl=A&gY8B;VzfWI zCf+oDA`sJaeH#-ITR&~6q6pxf$p+KT#CRTbAKc%!m!`=Gs+dNp7-uk6dhReP?5vng z`-PXV(J7?cwF;@wP8!NU`Nk+2rfFcpv#sh21+XB-7=3cDM9S`J9uUjVzHsu4(MCC) z44a!L(oTWQtsuEpwMS7n42w<&5IL$Bz`%2Fw8Q=5L81@Yl{%*$q1Aoc* zzk3=zc|oiBDf^@7{gQ|G9Q*o@$$(YiK`}-Q+2FD~8nsk_$UhfD34WxZbIVAW+d-;2LPZw9)pR-u@OFz zr3hAxLCUa(A4dt}?CaO_c?>j>i^4n}rsE++nwd;PTI#0&g#AYJcO8r&g6gCyR~Q-6 zLO8FRz-8S)u&Yp)Hr$c$_j*1i!1m8=*pNSaDZspLZT?Nbp+7wmSmS`j7TR?z$^YY#vWw|e%AGMJN(v9pC z83FKen#r+aTc-nT2qO0N=ZFD1xpa{Foo&{N3GYuiF*XK`Cc_M{lKa7IptbZL0l_7T zUM#lI(pY{@LC(RrKnDxh^-3cGuj0YSuoJw;s7!h1GGy3SxI`-{fS0P5*`pwD&wmiP z&~v1>kXs{SAk(AhqcJ69jfj9cM#McR6b0=kI+qeypi`DtE*j*02yBK}wvoQj^ZDvt z%O`lD2Tgvj=&6lFrp{mxEJih^kpE_51p3})`#+qEIe%^^ zI)xI?9sJVxk1KckGcO}ORw+MSG_W+R^lqdNoL>X7pXIe7cQney8x5!2HyIIuQXKziJ7-Thqgh&3O49r*HiOXQs@d zw3_GhFin;VLtIeir9PL-M-~D=w3ST0RisIvqH9N(%tMqE*xdMmp;9UsAVo3`>tRl4 z$^}9}mOb~u90{lZ0+=geyTZy08>5x*DQdG_52Jtc{2#{k^v#IR;b0#gz*W*_R9br; z=c{|YfM)O)`Ec_7jNce#Djqm=#Uu~rZAP4H+%NO*#aN>qCEeBMJ?e`xh0F;EG7|%Fhad~afJ;h6h{%}K;#`IF}r*nfSMMge4J_8Ur%{3O&Z2OazWuj)Y$Ok9e zfv$?)7H{`_-^V7$zK?c#RANCbz<)zam4}Krd(l*dT=Qw#g{G`Ci z?zzp;7z$)@{9`5TZe`|dWr4GEtTZF}@hdma|Djw@-;DVDKN<)7WWl4s`-cmiefB!vlOTAUr++P-9?KWon)5!NzcRI*V z;~Lg(yq9_csR)6XG`ibNOQvV{t!Y$>4tiBrRn-b&KX# zWm^JsirN@gMr0pH)H3A8=m*$3aG zE@ny!V5sm4Y;qcT>Uz+NGQD5Q9Dol^A%n>aml3w*ZKYFI2GP)lLFbInvOh5tKnYt3 zJ;oW1e^l#;fYJb}l*ininb3BkOSQLzkqiVCAwgY*b^t;Vbp=KKZUU2ivNB13GB1IV z`on03lvoKFp?voyJVHZHC*mmUHrxN4ly3&zam2h(j4$5 zj|hbLT!n@)*v43ZF9xOC{$i!DRj$tzSSJc+n-UfJncFJgQJaT(GN+G_l!wh6!Me~+ z%iglT<9|V%eUz_dc`KT@ai*6+SvJw^11_dmPwDA9ubYEdpP7hm9Zhx zm?<*?=?1;902NC|fYF81%iitxK)aG8Fa}iOC8eqQ(ML}amW%z4L0|TfhE8R3zkqB@ zi=q$RDan3pQT7*MI>2e3?Lw$O4H@)HV2pg6)h|wx+sQZXn8hel@8)i$d!?fT$c&!A zP?oT`wZG7Wo<5$VK3D2idcCaPiaME`MxS#`T<^25YD*b&fVQ$P4BM)AjYl_}*-;_@ z5g#gysY09G%R!ajIX$P%3FuDf8p+*yFz)AfSMEf>0Rdh#K{a808h&WvUHEoH^16 zQ{pOaub*iYSH_siyLeKdfua>bd4M-vyi{`@O6tw#-;~_4fhN}%Qy&)kq?vwNoe((X zfuojBkfd?l?w}7)))alTF`&~X`to<#f{Y+wr_>2p?v*Y9qraPNnA+LvJeG)gL#5QY zic}bX6nn*pNmzuq!}yc`)NBGefseBdxfv3SKGQY@pn|W+L!?!XrvY$ow52&9=Qxcx zdh0JvA(bRNk)8xN-y6>mENlqZcRWwvP|l#=MhEjA!bs%^+LZw&1F?;55G)aS8>o%< z9Jp{G9r!d6w$BVgp=3mXp&-#9`wKwhv}B)hO!VCtx_AZ-iqw{K7c$lB8w(|KwPT^^ zlP5RN|Djwu()Zb3>;ZYR?~kI%2%Oj0(zli8+jc!?{Y%COY)z(Iu_o`*S-=i)8NQ5R z;CO~dMAZjUY}_uE7ePylQOEU#({l?mIsyH5p3>K$OD1_nep!N#f`MlOh`4)|2Fn>ag-l^b02nF_2^DKYXk* zEQ)eUYKG%^sbwdC*=pIXAE)_!=JPk>{}3)M^?MhfQ9J?Vlgs-cSFK&o4S(vI|4uI^ zVyy?LL+Fu8PWZm3(WcNRjf;3(_uTkGPjIzSD$tYfGev1~rT)nH91R=)r#`nq&*Ds> z9lCr!jxu)wo3AmnOYgdbjEUp`hSWAWDQ?X4T!{dxA966-F!b81L=(B)YggYHrA_** z4B-a)0`L@F-)b8@a^!8-{W6z}x{!{Nyl&9M`{f{yH1)j~A`m&kv!%aV9S_y9Uk-a9 zx|Q=9I{LAoH9=wWghflrIJ9ghXY;*SkfIZzgpWYf=7fR}7GSUvuom;RGTbc^u}2K= z-8U1;&<4XfD9gh>{5=s@ROUNwMDC59o{=e{B0#;qTcQ>~cRq`j0wbhJI_P~N?cxD6 zgrJPB1z4$!9)-CRVfLY%9={y%na|#i^nD1IcKCbmc^sZrm;#hh9LAYP`c}ZLXWCGx z8x8l#qufx8;p{x3^?W|lSm*!isSL01U>M#m@)eK;Dog07G_dX%m;qXOr$yEsaIk(p zgXgBT=c18h5$_YA8VVSB^+{kc=4V8UDav%VmAa7Nk6msoL*sCbGNa%5fQK`wj1q2#kx#o3QFME3 zb&UHqCZ8UHGkCFp-t#wt7WJ(FsD)lv$IB_W-RA&+`PJBhwqrEJJ5GdB!e3-S&mqw$ z-a#M@E&H0s;#suXP8SdnCsC=jD>w7NpnIPjsf`C_g(w>3c8yytD^8`U-_vuKqG%&G zRy+X=`t?!Gr4{51z$JPVWp%ONp|r?APoCVC`aP6Ov;BXFQ~k=avBA^Fp+3j=W76Ld zlJ$PC4$&19hiz`etH#(XVgSx`8)#Bi!0Fb9-=-@4;o7GdPZqapi)xN7AbO1y_~v- zjf1#-akc?%_sAF9q<(UX-vX)teEED?PPwqz1iiYyp=}Wb0K8a26yAZdy(|fO64JwH z9!gQ;BCrevomquh3W6o$L4ebPkZ}iNE4N0);Gy&#;Vgm_3WD_RLvRlso;apGOIE?@E=f`&$JQ#uw z-qzawetKX1F&qSy_3d7%rptC&&s8T8U zfOqQ~{@i1VfVXwF`=U)(4DfiEY;-8nFg&^C{2$Dvu)k*eOYw3KjjqXk3i;C@f$v*C zqXPn!CjU9{t`GT)fPv{zlol0l8fh3fc`DZztS)-6kgse9b-<3rX}$W?nhTA(&l(d*ijLx$tv4io|0w1Z&`T6DCX%doQ@Fm!rP zOU#O)+MF-w}|<7`yt( z&_|sAO{R|zRgUsE@IQgHu1>J4GOslqAtgB-T4lyF>o-uLo&HK10P;eeQYMK-Oin_F zPM|KMx9>~BCc}IWU~y$$948nVr?DTmJ9*ls7fPf74hIisa($b*dW%=@PHUtbvG z5$H3+?rsy@St@;@!{U8L-?tt6pTwY%+m>ZI*BrUFV~&bIjE@_gL)Zp=CSb}w%Yo}r z+Me61({r~74(&~elt7jWA9Z^MWtCMl6#%q?)HOgUoCu$lllDw0PysY_x}e$fIU38= zWEl+vQK*MJN~9si0P4z#6%mEu@+eIGiHPQni1?wZF&PRe&u#XYBTNCBVvdGz{%M3fdbcu|D^O3vvYemp zMDak`XTDb)Fw+O@Z!td9t$3$+&*mICSfHU6uWZIAzR66{oo&#Ds|KVK2`0%ibZm;j zjHBcK7^rcL<1zo2DnGyYdFhg0+*lrD4zoq2Z^QnOk4rl!7j#Img|dUU zl#7MkWjc=kYXqS6VbImk>lh$;%nbiKQ+6<84(!DmE5m$O379i0P5AP+s<@8(mm&N_ zuNnZB%PWoar4sZcN-?gfQ1!P)LlZ!XiEuW zpU-gOn#Ra?_bak*^@^VPxmO*YeI+meAY$7YVW8nJtB7DPGFnQH7KV@SkmkDYhRe(K z@nMvut;&q?ezd633xGVf+jIYn=F;{NSduomFAViP<*W@r#4#&-;GDF}aRVqIuj@Id zTX6>au#Fidt1`2puxDieg-Ym2llYDvX=oT)W4M~36Lvygxx#PrsMa$^jmco>r2G=;Qm`HO*3e@ciaq6!HHQFP^+V70p}%i>s=LM=H!GVylg z`fp+XhjBeUjlY)qaYHEWqG{ppV`l#zyXj2O(56VxdDJqnuh!FS}@ zA@I{Ap9P{TUOEIX+Z|{d2A>{=dnWAH@u3cIQ9ccSCv7vw#d!i?W=1DUdI4i1;glhG z{PJzK|3kT+zIp8J&!v75WBiM9JAaz;xe-O_>pQ*CjJQ!bQ4v~-AWwax;)h|A{4i}s z4|%&A1~+2#B@G3PKf}8SaSpE*a>0mQZ&1LLBhk_H0s%opHN;$ADY@p6$OKTXsNImP zlsa|mGKUOWOi{=4JH5a%^lhX?3gEfcI?`y~hyXJA7vt(c)1xtE z4nVYL*$5Iw9p-Zq#KXI=#8dxiYTY$+6Ik4GhnvWb}y3NgS4 zo-?5VopN-TgyEh)j|$E981gL!+<;AlF-fyLoegjbr6cbRZM;Oh^h(4(_}tC-KZNV) zn-QP?M-lJ6TY0#IRSAk=9HZqP|IxSekRKTiC=Ggb5mgCMrljONT}BVLnUd2y1uAou zQw##y-#3Kf=_Toc9^m}~5~w^6WmuFn%b`par^#Ql?}95!if&{ak=79rQ+7Aix$M@_B-i>?6wrf6+dyqUf{_ z=YNX;ECz;#cFGTt=oZd%&pN#Xu2BW?WIW4hczhq7cXY+iLj2u`#wAEaVGu}oyPA%@+tW{BBniVc(kwT$t8oTpHT+UZUI=?<&Q0A#LH)F06H9@~pQJ(c^O)dM$^9l+RAay_?$is$CbQTg&DsdKkIrWI2Hd<0$&xOk;;Gy}+ud3uW|CrdV~4>@hx6VbPvF~Knq&q#06 zbH)Twg5ab*0VQ;P(ggs`=M!YVJ<1hBr>A2FdJ`~m9#&b#x8R)yx|g~jQ|^ps-H4{7 zd;kEI2teDPPbh{ASqvOdgd4+boS{PzQv*~GFoh0^g-1P>klqSCMI~~#GU+W4DHl{7 zqrXJF`n~nTFf-Z^ZEV3yf#$j44f&+DU_||vIXJZ&=&s~(`zW(`Acj=4jS=zTkKR20 zhjQsuzaQQWd7NeqS4G=|W0Q;dtpY z3|{uTRlO5^f`;WG)=&b*{@yG;1tu5s7GFHZe ztXEf=Q`4-|=fg z^HCX~gp$toP$)J4g+#H7FddDfBPK`nI^j(1EwgC*nIE1?^Z&qKWpDpJ0BLTDaxtExk>}?)o?GSWB`b`yr2%42UVLgmBBHLJ zX8tpxdY;9Dqb@Pe@V@6GhWtmIa?mfemw8lh29Ij<(bYfRsQ%Kj<_=`M#}X7K4tvXI#SUfqK9s#W+HElc85k z;KW%Bq;H-ffcCGpOj0vK-_|Mo6*2RNos=>3Bx%V1z?C0!swujpVima&k28@ zyLtW(;nI=5@A@ROm;mFko*%qiWM-?fRTKu4RO@x8yaJRZldde!dff1AG+rvVS=sJA zZ&Sa9(S@v1$t$!Tt!|m^hM=4jhc<9>o9=U7j+88&0!9W(1VcK|0Mka=#Q<;a`O&a1 zy*M~No}U*ONpJzZ<-C)C#iO3;zeNwqtV5OrtCaNQkLB7;U&}EJX44L|D7um% zZuT*vc&Mx^qp+KKq%r8{&dB6J>-%2&7-1X2(X-xbn^SMWKxJRm9_hWyXm^5$v+X5f z62rLW`=pn??$!sJ+$qN{VnkUy@?9bYD4#M{=4AEy&a%&DQ*(T2l*vzKy}=3l_H69JhXEzQ^F`dh&%>P%DHH<@YNCG(m9kikD5VLQG=-!C-^P(rXV@Vsd>zAh7w;s7Jmfh9Fz!=&QF1{i z0YE|T<+CV9WfjE>9l|gwjSM}x$-$uBVt+s*O}^p=X)6PjdPE*Eb*9O90V}=Ip+sg{ zl=A}m#lTFf#1?W1Ma6kw$CP}AMLNz z(*S>v54s^Lc^C^}O`&N+J7k7wE$0?>rfQq>k_9M44Gjr(Gt9_m)5D;GG@?>TvR^sj zN70R~*6Bi%Z9QqR+N^{1lcqv+F32jE?IE4EJ0QigoLuLB3;TcIFKzGp?7bnAE6*J= z*kQ0*+i|cDF7Z6Ky>_5YV5!id*0H;>D<`#u&9BKyV;CLih@9wZ;AEwU`I%l~X+QLq z8_Z?~(fgTtOW|9}C=Kix#gH?QUV03}Qi@($+d^ftf)qtH`dF?Jf03!;Ivk(1qoJ$J{ww_wuh zJ7s2W5n@a-P^Co)0qKd6GW-QFA%bMX%xGsr0|K&-^7AZXfPwuI(K|W?B&dl_tY;eN zRtXzKs2ayM1ox;oMwA5QWw@!K|1my0lAHihjNixrWv#3j>g+QoM!x(w;xjkn{~#{q z{C|jDDU0$vc{`2XC39OHdn`wQcpY!eJqhmIkwQnyGB31Co{WSq2^qM;kgi2=OI zNs9))a>rEkGsmY@TC1$lzx1Mbgf0W6bJ<6CTzrZ7oeta*^mc|V{ zDLuMfYcg7P$oYWd=c!%(M!JOYmrqdF7-bz6Iq>KfQyuC%>mwjrMc7tRCXXb$4dz)E z`8@eUwqs%JSvMWxQ*LO1zC9iMFn${axxhIBp`l8mm{3qLxZx)frLZTFX>2LXQ9DdQ z>o;Qur4mvIohlgK6@Mgr>ZqYXP2ob>PRgZXN)jim}=bkU>Dt$EI zRQ4X(YYJ4)$l!WJlF=FcpQ6D0e7>X9B_jhGN7kGCcdZ7T@9h_W(q7xdtLVy=y^1=Y z#iPDTF%$ym`Hr1sqSmQ*${Ft0+SceOqgA~iC0_KT47~#x>RlKb(Vu!ICrSbY z6k`NX=cm(ol$83U=;WB&%?W5eAH(}W-Y5ph>MwF9X$%BYin%WeJ4O z-;@It|Ku4Q=H*IR%dnL_*3a_v8{aLHtV|9woGoR~4S1+?%==G|7RH9&X6lqSd$%Q8 z5o^O1QIEzgp!@7I4+|WVii5kG*do&~MNgn{Lc*z}h2DD7i8qOh&Vt(JzRybFxGt3ECOB#%L*zt&$t2mgC=5m>rXX*3)hp*9CtcbA3nYH# z-oPX|Q3He9ZIH5DQ`(G-e>46c`0ME#V|!mlyXYMhp1$NsemxB;!}tNC z0REEEr$Y4bZYm2Zc{48hKP^YXRibii*Qxx;;{p$CbE7OmZc2DJs**1X(b}Zh)2F4?5+D#fVtmw&WeQAo46-4h>$FuQB+H>n3JrvZ%1Xp>qnrey02^cTD*1kTR%T!)1|wPoLepRuq}Inh zkDe$W$cY_}ny1JB7+ZaO^5kaxAIc@;|K77m9?GyPlo^#l^jz!TS|2HZt~mga-}7_C zP+-My&~U=5;5$W;zYmCCMZu`BCnMKJKgHh!*DRyVvJO+yu7G1GOx|-ZHrv;SJgU9- z&Z37hE;vaHM>1kx{mW<*VT34TXIt4VRy4qxdA<8sGt0J%mX<1rfqk}gus-C zh~ZsP7R0{*u1l|(UIb)|iX;O3e1`U29tt2g8Bsm-Y9q$3bLpLLwa(&684xJWf+_vf zJp|`6OmGmW)R}fneFQI3^^|!60QQ&ntZgx9;B==0Qyi~=Uf|fmC>NfIQEmZt_)eoN zI?S+b~Zc% z{zFFlqy$bL+q4@UltbAcUf|dbhWL7qT&vaF_#EwIWwulNyIDVIsLy-T7T8rm9qm~3 zr^R8Mu3F}&=lf`18kEzJmHwdaT;|rtEcEj3KGP-wp=3lZcyZjW9~!`j`p_MORNBx9 zG;ChB^g^ycI-e`fo9Um7z{cR6$x+c~PLQUt1E5Mzmi;FnYh#%rBSpMKlQev*G*63Y zC!dzRC%V~os`zSfR}i&3>1wvKAj_<`^wkOL5J zUwH=KODL%g2FuV>pZi(*E5dO-^kSl5eh11XK-i#6RCcC1wz*HK)UQJj_RDRl-$S~d zz8Ucw|2X3LvzZSpgxDvK4n|xTJpo0ultODAp+y)Dm76(N^DTt*m!BgsG8TqsS*GCmNjK*Nx0X7J1Xe$L)QnoHNHJmaASt)(lYrh$Z$|hav zySYgc_+OW$^7EJWKfT_g?cYs;O>0p(z~{vSF-Sxj+=!8<$%;n<%0RI5CHKjY)LuKu zD?=<8c%pt^5;75|#}om{zN4LFEDm;=dl2KUCkTIO9Kzo&|6bXfRSiBTD08bZ3ONp{x=~x>R0K8@#`zA6_>MANiAG z)2*E)IzV2*QcFf+NLzs!jf?Ajm7SY3L3TX!pDRtwh|^$mt_ct1QGFcClCBV6i4GR+ zD9b*#GsHjSq_rMBH?4{BTSQfg>9u>PmNMgjANTeC zb~h%C6Inm&S@xN3qkdN(a(@B= zQ-L`EV)nPBA{3DUh)Q`!-&xjfw6?2Kx%Kai!ojGccU2JD9R^AQ1j2M1sPq;Ly-Go~ zK4hLsA)@Ap5-gyTPAtwFD9G`w^Kw_)5fNj9615$~P4r8UV4%T6{ZxR=dWI-Zm0S^^y?EJIsX3ZUy8W9M*QAaqks9WXs^8y@y0t5*Mo1$vcHIkIClWdkhpI5 z%2&~2od@kzc3f_#xXAxhPTZS=&#SIg-a5aZ?<${~-oAODGRWyAt{efhL0(fj9hjU< z?^mGQ!_C>uKIpB zrEx}7g?e@Zcu{Gbp6gi$qXeY+a*n_@SWeG00FfX~+eQ7Y=g`6OUaNg7;_(GYa^p`&Aeu)2{I^eXt#SU z5-cszaBsjp|MWz_{LJfUP>R=o)_vRI?{o+ANtge@s@?-cKp5Ib~;JfWg|P5HBpx?j=-EoQGMAjhVVsc3yZ zLy0sJs6*lN@;-UF307#~*tWb;$HtA3>blSCW)zhCke(xGUM45-}4-P z-%khYYWmHeb9#}NvJcSbjNJH|`~rkwJIeBG*c(PqBIa?M_IglXc`e2??aXTPERC6! z$?;5Ew6I}rO9(baPftgQt{ZG8B1jei*xxR~UAE#hK}}&{XP5^8Ge?W!StrVNuY&|x z2vB<43x=HpIOeiF`amXgl%IkqX3;y?M8ZRO1RA2p>Y|rIz@(7>ez_g&bGwe~-}{k> zzwy^1{>EQBxayrhj(F{@=wE&_;^|k8xddtak^6KnK4nG!#dlDoKGVGzen!eHh38w3 z_S38D%3B9?g*?8q9s%C;lvOvLkzDVWNRLegmvDL;GUZ_Z`Up$>U^scv_){Mdt-l`H8omwButEI~_1kPFX1Y*)%y+$Uq^)BmZG15GY zBIO}Yr@r(wK|>@(28bz!j{b9;MGrbw9(9eVW5Vc%cq46&I-zmfMwxonqbi~S+ppis z9C;PrXQP{CIX{xW3>>FHhg>G_o&aP+guO(DoR`3dn`inqp_skQQ@x80OpFG)yhnVslQ2}+g9BaexreO7c0T^wTANOX90}xnt$J{BS z1Zrc9IuvHju=HN#u)7{IkBf#U%iHSnOe+*`9y+8LEqJ&-AE1QyVf6tKKokv=||UkVVIfwN<(u_mbvvUNDDgit3=xbmFEt53Kvt=Zq>(4yXVWBS5al z(cU!b0C>48Cd*!pXT;2(`u$R$^MjC9<2Xgk)#vj!ZNKf&0bU-x_7qD3XDl%^@MAMA z;;j)TSPWu0z7}Ce5!Q{UP`q#2!wxVE&wcV4&LMFebr{TFS$l1jVXYD96*D2coC0Mwt0{7A_=YPBYoL7s8zxvULzxvT=|LreDJim+hov%fI z_3emP-->wi+Yzy!O8}`XrTi|ljFaCBXzqi*BdQUT667(cX=o7@q!DqW91#_GTo*DM zQI#gzPR}>%>h(Lbv3S!V892LsmnF(w{4Q9H8DXy5hG3F00N zgDx8CawAC3QR7BWxuh6Cqe=f=tC20v4je0%$GHj#$_S{!-h+;A*NBc&C<_mXhwqMO z2qu$eD$lNLpMk6L-{gS&sy43-HzOpSeT~6)49_nQJ~9x>cDeEIM$3p%b{Ri{3zLTy z5q+za>VN;Q|EJOV|2Zj!Y^b4PRg%baMR=hTp#AhROkot{odv+SK7!%2{0E8Sq4@$- zBm})OR`zADL|c`U-xsuT;Vnw64FKabia0(0&)YRxQ>6!|_EKT0{@vuzJ$>}2$b3h;2sU6C zU1k#mTw*fXKIx2xH2b@cHn|sA^p|<8rAz=cp9Ch_apecSenvV7eD2d<9H(i{gn7uL z5u;4CY1LQvOiQ%X>DY%O>UL)}_a!$-f)mJaJg)?$Of^Gy7n;QQec~4zv-?2C)L-y! zxs2aACL9O1ht7wk4M+Pb0Qh@9`y+g%d;Zj2}Ml>PR59G z81**?M8!D?yoWM8jgsN{eWVT@{7fP6wl;1>x7p-lHy$hJjD1@fp@?%luQ3@_(!;%f zUd6dlGSBb;03ZNKL_t)cuEl(m0zg*uC2uKGu^OMKv?e$$#-G4~?Ohm1H@;I8Yy}3a zA1hlm(QQFjwW)v?@RX-c*oX4`Ug^zx_}ifQvxyV>-~G8ijEIjeQ7tcqFE-106d^Fg zwKB@|oZTyUKf+GP$eFO!NCDf&cfGz8EY9RSFNSfZHfAMJ&Q2im7Zau8+HS`GcKw^b zdXM=1Z$`ZOcJx=j8u9zzIF=wr%u1wRVhw1+?n~DBTdU9aR+n_81K~58T_Hm@-KZ7W zH)nwFmaUKjeKg%cN5p?uz3wd%6isQ0ZC&U4>YoKLnhX!UK&xxgvuz&3Dd!F#m?7t4 zFzOhTxetZl)^F=G+u*uzGOz}yxQ~E4s&v?sDNh2 zs^dUr?DQ(ESZ3pz&@ya7rBNnWoP$!rV2^AkD=3*%Kn6BAc!f}8VhgcpfZR(e$oXv7 z?O>nV^%vp#;d>GP>}%1#^wo$jeI?@U@1A_?UQfg2hRo~!eV!`ir6^)k{)#e2 zGtVp{^}~HmN|tr0&#Rs8>`a~;3_>n#lZU!6ph*iDq(Udt9j@G|%w6w{7e||=d%Ua+ zjho(O!0OHat=R-DF}n6PDQ2^!2|&OQOHv+^kMDVPX6-5pFEQGX4$cnWPYrP6?Tp+Hv9>DJm9-)8!URED!c{!H4 za?#)b>zWcr?0aG-zn3-9P0T9F-Nf9_j5xaUwDP+iKcSeg2}8|mOK|DjGW=%G zwZE0ln79ACQ$XI^-WOp0dh&eqyGKnWwy*vx`~p6AXrIJeH3&O8;kk^(|G zdG2u%*HU!yT{&2w-E4B5OzOrPu=b*>NTNGpHs}vr{Z2t8NFC;T6nZq$69i0t@MvBLdWh2KswX zb=t_yHb?_e4#%PdWaj$Ocu5%^n2=T764Is>z)NX(BO;k&UCwZpaI_TG-I!bRtx+b& zw01t7A@ECNQ|}8T*rT89dLnU+K^c~cjkzASs0e~{}YcHl}(svDc7 z#8)d?yngmkz|_uI%S_jE<1_04P&q10E=WQ~O(L+GyTZZ_`&p;oxbr(F?|Tg?a!z*H zWdfc{ZQdr06Wh+Qd!Ht`4KMGaHsAibI$OdcWhi;54slS^(KKCFT#FE@5!Wm4D+~`l zvc6~VfJaB%%q%B5c&f=)@||2QFAp^C;2=Xj0&7jrhA|sebf4HJ3>~P5sUip;Tg%dF zvhyb8MW`28$2Mpx?nA!YOw2}#|8OH+rbt8zZ~kFf*Yzg5;zpYp_kurrZfC(aZXwt= zjCc25Xr>0^*nNqwx&4lCixVJ?Bzvfv5~+j16pA~vNweG-`&23$@!2-EI58Fp90a5L z5NJRd@%K&OAwiOzoQlRGAHo^n?X0`TmXZIOlSyJ(SSXnkW%)f0@J*3gDhRItY<)X0 zE71Mbp^9_qKUf&)V%QR;7R<6zE{1=#DKm(fC)agN|pZk1S1tL@;tm+rSkzNquNG#!$qe6MzP=)rY40P!0J-jW?vEy*4 zJ7>^%gJf?))T92N!@J%m_cvGRF3zMhs*-*}<_J!4aFTR8eW(%Viimh$F?N+gu;}b? zs6>ml7YcVM~g(Fx3u2BIzkgSU^=@4lzR zSD+X#mLyV-X^JKCmt`W+%F|?TyT`J$u|-93{ZKoLjf#V(TZ)`}h>G%`3fd43o&2SA z^c7=yIvkr#z~Ybga?p&*^n1CX+n4LL2vIsna)e%M{K>rPp$d>Q5f8nHb%n@1`*ZIs zhDCQn8Den@i@N|u+ktpU^1t_&OgDq#_OcC(puIcz>4KU%Hr-{qboaQ?ac;ol!rQaP zua_t6tDRrZ-L}>-Sc)d;^=5+Ip{`huzsD`qt_roFZRnqopjghWRzGeRWS3iTX^16+!9N$P8xZ* z7DUS@+ma>Cr=`zBww?4BnB?Y@q^tM@l_}w7{4|qfKZk3yn~_z=zK}-zO=IwN8M;S9 z-upl@%JdvhmY&t_(cR6hJ=USL%^xHWHc5HfV(lVNF(0e0Y0P3MpSWh>3HE4OB9l!( zk+gR%6o=ro3^&n1IB9Etf@GHqZ;@xaqt&e1S&gL4mBWGt2$*5$^weMT+b5+7I*=+E^IFoYRrNboSl5Ce1SLvkH%2=YdeY zmoB@G{{?YXsrcRMRhlAdVp}n^3R=bCxJI{3FJyH&v;Yg@@25P2k2Z_HOySY$mVB5y zLEPMThN4-ooaH{OueLb9t=^YYyuj!Y{d=Fiq@R{kf?c;YO)W_MUTD{xqGHKLxy(7N zobQ%d=n>qT!s3<52Oj zYWgsg&ZfKygn7{Ahn-QdVYFJ`pkS~5nM$C9lLgk1!9 zUL1&XY5nu3lX|oi!!nmK{hq_gIpSE4g~$VELq%7ck1tdYa1xlvTISy}oLuyILfL`z zWx_2IRXl3|Y@&BI$&4;)`pL46a(JV|)5HBfG^>>LrwP8WBTerNo_P0Ye!_U)h6bb{3qa11@}d85r{~jfg}Fyx+6-XJ$a)S;!4` z?W@Zn${tZH1|%A*4IR^e9@GAAz0%H?bJga|AG-e_EY%Gql@*)QjU*!eidynzd>K$p zG9>ndKvrQ*m-VOZBg@4}vxAW>A$AR`CDZy`f=bjmY9hga4d9zeku*`QJ8Y4?i|ES+ zQ+od=Oh+}$OAscw(Hn^;5b|$+xM|{-!eT>;j90C5o!lBCi~Px1%Lnw&tWFH2qUWzq{2Ld$Nb(Hi+y~eb4LhbT27(@F&C> zk@EyBgZWTK2pXsE{APJBBu>vFs{FFMunNa^sk*0yGAaEal`kfFbEc(dJ#T2!DF0b0GJQl5y-~*oBONt&1(ZVIPfh4s`schjXJavVFpM zKyUU-@YI_3bPb`&lbG)*QgIdAv4KIm#fpPc~1CBRw2D4zE2Ab#;44tK50g6jYzb)j$eA&I?`=8?G>H>(;P20;hs=|!h z37;?I=I>sjvv|ID(xMXI#c)q$$4-T_MQ9(*CUS|jHr#^K;3WGN(ae3uxH<1 z>$Sl(i@lGgg9L9JPy$%HCb$dts>b~l{0qKz*nt7$)_Iv=2G7O`R@y))`(< zv`*ij#6QlAJbW6{>JzdVeXzt*3)2_&CDtTn;zrf-S+?Cp$hzp8?Etg!bLg_6$|=&) za5;&Y3?k153_aUv_owkCehF?FK;xGfWa84GmT{_|be;cA2LD6#>Oz@FWfsT{)%wKv z^oY2cAG9YH-R6xUE`58}O0$zLS;AFmLXpb;S-{tCC^S^LIM)BP+6|BAIRQ@xRIb3g z_Qgl?V!3s*TS0cpaX%RadYoP$%nZ0V*@^vyZ(zo z{F}eO!(@N0CZ!F~sM_~m(zV>fh`Ky)n#OM^Wn&pSdhROBgSWJ4yXKBgPb1OssR$vIUhxd#^ zIk1g%jO&~pPSUxERC8_gdHs7Jcr7|7N&C%twv5I2m%!#^1(F2KhO7Rn*(FJ49u87I zBl_Zw4;s6m8b>)DTN$qWPuc_8ltCH|Ui`N@-`yKlyUep=l31 z){Na%$ZtGAbbNvrGPp*cx_So~ptg0TA{Few*jWx5L3ktsGh~j>svj)AMZAO=sb%97 zCy<9F2=^t9-!=v|hBy8|-M!E_w0)O5CSzPy@o^|@AJVc!V|aA{0La0caE#f7I_Ju9hG-sn{7j+%gzdWOnpzZ zy2*jg1P9?5ma&1xFVC;qV#&i)7hXZP@};OpX|ujraTd~?Oyqr&mwerZL>CCt#M9$G z?(}l&2juX7x>$*iD?Z9&l&B4A83racDlwBSR`yuENURdcl=V$Dm;{T*+Hw_6%8hVp z;bkSGwU`dH{Un`T3`w!sN^mCc7;M1=nDFkb_ueRp;nW0+`E{m^(lbr`XreTt7WVt| zQDe~h7k)|D8*KpoZtDk*r0>&W^1Ho@{#qE7i=`jmv2sI0UVCoG93y*Sx=!AIH}s$? zekq4t2W!HtKbWobvDSs>6|tGe^ttb1P%0Alqi6e`R9*-RHY{A1J_h!rpC3ru6ZTA) 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