diff --git a/cmd/start/start.go b/cmd/start/start.go index efa0e9326..75516fba4 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -38,6 +38,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/user/v2" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" + "github.com/zitadel/zitadel/internal/api/idp" "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/api/robots_txt" "github.com/zitadel/zitadel/internal/api/saml" @@ -331,7 +332,7 @@ func startAPIs( if err := apis.RegisterServer(ctx, auth.CreateServer(commands, queries, authRepo, config.SystemDefaults, keys.User, config.ExternalSecure, config.AuditLogRetention)); err != nil { return err } - if err := apis.RegisterService(ctx, user.CreateServer(commands, queries, keys.User)); err != nil { + if err := apis.RegisterService(ctx, user.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(config.ExternalSecure))); err != nil { return err } if err := apis.RegisterService(ctx, session.CreateServer(commands, queries, permissionCheck)); err != nil { @@ -344,6 +345,8 @@ func startAPIs( assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge) apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle)) + apis.RegisterHandlerOnPrefix(idp.HandlerPrefix, idp.NewHandler(commands, queries, keys.IDPConfig, config.ExternalSecure, instanceInterceptor.Handler)) + userAgentInterceptor, err := middleware.NewUserAgentHandler(config.UserAgentCookie, keys.UserAgentCookieKey, id.SonyFlakeGenerator(), config.ExternalSecure, login.EndpointResources) if err != nil { return err diff --git a/internal/api/grpc/admin/import.go b/internal/api/grpc/admin/import.go index 438039337..f905af159 100644 --- a/internal/api/grpc/admin/import.go +++ b/internal/api/grpc/admin/import.go @@ -629,7 +629,7 @@ func (s *Server) importData(ctx context.Context, orgs []*admin_pb.DataOrg) (*adm ExternalUserID: userLinks.ProvidedUserId, DisplayName: userLinks.ProvidedUserName, } - if err := s.command.AddUserIDPLink(ctx, userLinks.UserId, org.GetOrgId(), externalIDP); err != nil { + if _, err := s.command.AddUserIDPLink(ctx, userLinks.UserId, org.GetOrgId(), externalIDP); err != nil { errors = append(errors, &admin_pb.ImportDataError{Type: "user_link", Id: userLinks.UserId + "_" + userLinks.IdpId, Message: err.Error()}) if isCtxTimeout(ctx) { return &admin_pb.ImportDataResponse{Errors: errors, Success: success}, count, err diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index 4c81243fb..c9f9cc2e9 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -241,7 +241,6 @@ func AddHumanUserRequestToAddHuman(req *mgmt_pb.AddHumanUserRequest) *command.Ad PasswordChangeRequired: true, Passwordless: false, Register: false, - ExternalIDP: false, } if req.Phone != nil { human.Phone = command.Phone{ diff --git a/internal/api/grpc/user/v2/server.go b/internal/api/grpc/user/v2/server.go index d81d51ff2..13ee61177 100644 --- a/internal/api/grpc/user/v2/server.go +++ b/internal/api/grpc/user/v2/server.go @@ -1,6 +1,8 @@ package user import ( + "context" + "google.golang.org/grpc" "github.com/zitadel/zitadel/internal/api/authz" @@ -18,15 +20,25 @@ type Server struct { command *command.Commands query *query.Queries userCodeAlg crypto.EncryptionAlgorithm + idpAlg crypto.EncryptionAlgorithm + idpCallback func(ctx context.Context) string } type Config struct{} -func CreateServer(command *command.Commands, query *query.Queries, userCodeAlg crypto.EncryptionAlgorithm) *Server { +func CreateServer( + command *command.Commands, + query *query.Queries, + userCodeAlg crypto.EncryptionAlgorithm, + idpAlg crypto.EncryptionAlgorithm, + idpCallback func(ctx context.Context) string, +) *Server { return &Server{ command: command, query: query, userCodeAlg: userCodeAlg, + idpAlg: idpAlg, + idpCallback: idpCallback, } } diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index 4388f69f9..97e13e05c 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -2,15 +2,19 @@ package user import ( "context" + "encoding/base64" "io" "golang.org/x/text/language" + "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/command" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" ) @@ -56,6 +60,14 @@ func addUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, Value: metadataEntry.GetValue(), } } + links := make([]*command.AddLink, len(req.GetIdpLinks())) + for i, link := range req.GetIdpLinks() { + links[i] = &command.AddLink{ + IDPID: link.GetIdpId(), + IDPExternalID: link.GetIdpExternalId(), + DisplayName: link.GetDisplayName(), + } + } return &command.AddHuman{ ID: req.GetUserId(), Username: username, @@ -76,9 +88,9 @@ func addUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, BcryptedPassword: bcryptedPassword, PasswordChangeRequired: passwordChangeRequired, Passwordless: false, - ExternalIDP: false, Register: false, Metadata: metadata, + Links: links, }, nil } @@ -107,3 +119,95 @@ func hashedPasswordToCommand(hashed *user.HashedPassword) (string, error) { } return hashed.GetHash(), nil } + +func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ *user.AddIDPLinkResponse, err error) { + orgID := authz.GetCtxData(ctx).OrgID + details, err := s.command.AddUserIDPLink(ctx, req.UserId, orgID, &domain.UserIDPLink{ + IDPConfigID: req.GetIdpLink().GetIdpId(), + ExternalUserID: req.GetIdpLink().GetIdpExternalId(), + DisplayName: req.GetIdpLink().GetDisplayName(), + }) + if err != nil { + return nil, err + } + return &user.AddIDPLinkResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) StartIdentityProviderFlow(ctx context.Context, req *user.StartIdentityProviderFlowRequest) (_ *user.StartIdentityProviderFlowResponse, err error) { + id, details, err := s.command.CreateIntent(ctx, req.GetIdpId(), req.GetSuccessUrl(), req.GetFailureUrl(), authz.GetCtxData(ctx).OrgID) + if err != nil { + return nil, err + } + authURL, err := s.command.AuthURLFromProvider(ctx, req.GetIdpId(), id, s.idpCallback(ctx)) + if err != nil { + return nil, err + } + return &user.StartIdentityProviderFlowResponse{ + Details: object.DomainToDetailsPb(details), + NextStep: &user.StartIdentityProviderFlowResponse_AuthUrl{AuthUrl: authURL}, + }, nil +} + +func (s *Server) RetrieveIdentityProviderInformation(ctx context.Context, req *user.RetrieveIdentityProviderInformationRequest) (_ *user.RetrieveIdentityProviderInformationResponse, err error) { + intent, err := s.command.GetIntentWriteModel(ctx, req.GetIntentId(), authz.GetCtxData(ctx).OrgID) + if err != nil { + return nil, err + } + if err := s.checkIntentToken(req.GetToken(), intent.AggregateID); err != nil { + return nil, err + } + if intent.State != domain.IDPIntentStateSucceeded { + return nil, errors.ThrowPreconditionFailed(nil, "IDP-Hk38e", "Errors.Intent.NotSucceeded") + } + return intentToIDPInformationPb(intent, s.idpAlg) +} + +func intentToIDPInformationPb(intent *command.IDPIntentWriteModel, alg crypto.EncryptionAlgorithm) (_ *user.RetrieveIdentityProviderInformationResponse, err error) { + var idToken *string + if intent.IDPIDToken != "" { + idToken = &intent.IDPIDToken + } + var accessToken string + if intent.IDPAccessToken != nil { + accessToken, err = crypto.DecryptString(intent.IDPAccessToken, alg) + if err != nil { + return nil, err + } + } + return &user.RetrieveIdentityProviderInformationResponse{ + Details: &object_pb.Details{ + Sequence: intent.ProcessedSequence, + ChangeDate: timestamppb.New(intent.ChangeDate), + ResourceOwner: intent.ResourceOwner, + }, + IdpInformation: &user.IDPInformation{ + Access: &user.IDPInformation_Oauth{ + Oauth: &user.IDPOAuthAccessInformation{ + AccessToken: accessToken, + IdToken: idToken, + }, + }, + IdpInformation: intent.IDPUser, + }, + }, nil +} + +func (s *Server) checkIntentToken(token string, intentID string) error { + if token == "" { + return errors.ThrowPermissionDenied(nil, "IDP-Sfefs", "Errors.Intent.InvalidToken") + } + data, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return errors.ThrowPermissionDenied(err, "IDP-Swg31", "Errors.Intent.InvalidToken") + } + decryptedToken, err := s.idpAlg.Decrypt(data, s.idpAlg.EncryptionKeyID()) + if err != nil { + return errors.ThrowPermissionDenied(err, "IDP-Sf4gt", "Errors.Intent.InvalidToken") + } + if string(decryptedToken) != intentID { + return errors.ThrowPermissionDenied(nil, "IDP-dkje3", "Errors.Intent.InvalidToken") + } + return nil +} diff --git a/internal/api/grpc/user/v2/user_integration_test.go b/internal/api/grpc/user/v2/user_integration_test.go index c389ee073..601e6ee6b 100644 --- a/internal/api/grpc/user/v2/user_integration_test.go +++ b/internal/api/grpc/user/v2/user_integration_test.go @@ -6,16 +6,24 @@ import ( "context" "fmt" "os" + "strings" "testing" "time" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/pkg/oidc" + "golang.org/x/oauth2" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/idp/providers/oauth" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/internal/repository/idp" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" - "google.golang.org/protobuf/types/known/timestamppb" ) var ( @@ -39,7 +47,60 @@ func TestMain(m *testing.M) { }()) } +func createProvider(t *testing.T) string { + ctx := authz.WithInstance(context.Background(), Tester.Instance) + id, _, err := Tester.Commands.AddOrgGenericOAuthProvider(ctx, Tester.Organisation.ID, command.GenericOAuthProvider{ + "idp", + "clientID", + "clientSecret", + "https://example.com/oauth/v2/authorize", + "https://example.com/oauth/v2/token", + "https://api.example.com/user", + []string{"openid", "profile", "email"}, + "id", + idp.Options{ + IsLinkingAllowed: true, + IsCreationAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + }, + }) + require.NoError(t, err) + return id +} + +func createIntent(t *testing.T, idpID string) string { + ctx := authz.WithInstance(context.Background(), Tester.Instance) + id, _, err := Tester.Commands.CreateIntent(ctx, idpID, "https://example.com/success", "https://example.com/failure", Tester.Organisation.ID) + require.NoError(t, err) + return id +} + +func createSuccessfulIntent(t *testing.T, idpID string) (string, string, time.Time, uint64) { + ctx := authz.WithInstance(context.Background(), Tester.Instance) + intentID := createIntent(t, idpID) + writeModel, err := Tester.Commands.GetIntentWriteModel(ctx, intentID, Tester.Organisation.ID) + require.NoError(t, err) + idpUser := &oauth.UserMapper{ + RawInfo: map[string]interface{}{ + "id": "id", + }, + } + idpSession := &oauth.Session{ + Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + }, + IDToken: "idToken", + }, + } + token, err := Tester.Commands.SucceedIDPIntent(ctx, writeModel, idpUser, idpSession, "") + require.NoError(t, err) + return intentID, token, writeModel.ChangeDate, writeModel.ProcessedSequence +} + func TestServer_AddHumanUser(t *testing.T) { + idpID := createProvider(t) type args struct { ctx context.Context req *user.AddHumanUserRequest @@ -287,6 +348,105 @@ func TestServer_AddHumanUser(t *testing.T) { }, wantErr: true, }, + { + name: "missing idp", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organisation: &object.Organisation{ + Org: &object.Organisation_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + FirstName: "Donald", + LastName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{ + Email: "livio@zitadel.com", + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_Password{ + Password: &user.Password{ + Password: "DifficultPW666!", + ChangeRequired: false, + }, + }, + IdpLinks: []*user.IDPLink{ + { + IdpId: "idpID", + IdpExternalId: "externalID", + DisplayName: "displayName", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "with idp", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organisation: &object.Organisation{ + Org: &object.Organisation_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + FirstName: "Donald", + LastName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{ + Email: "livio@zitadel.com", + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_Password{ + Password: &user.Password{ + Password: "DifficultPW666!", + ChangeRequired: false, + }, + }, + IdpLinks: []*user.IDPLink{ + { + IdpId: idpID, + IdpExternalId: "externalID", + DisplayName: "displayName", + }, + }, + }, + }, + want: &user.AddHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, } for i, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -315,3 +475,226 @@ func TestServer_AddHumanUser(t *testing.T) { }) } } + +func TestServer_AddIDPLink(t *testing.T) { + idpID := createProvider(t) + type args struct { + ctx context.Context + req *user.AddIDPLinkRequest + } + tests := []struct { + name string + args args + want *user.AddIDPLinkResponse + wantErr bool + }{ + { + name: "user does not exist", + args: args{ + CTX, + &user.AddIDPLinkRequest{ + UserId: "userID", + IdpLink: &user.IDPLink{ + IdpId: idpID, + IdpExternalId: "externalID", + DisplayName: "displayName", + }, + }, + }, + want: nil, + wantErr: true, + }, + { + name: "idp does not exist", + args: args{ + CTX, + &user.AddIDPLinkRequest{ + UserId: Tester.Users[integration.OrgOwner].ID, + IdpLink: &user.IDPLink{ + IdpId: "idpID", + IdpExternalId: "externalID", + DisplayName: "displayName", + }, + }, + }, + want: nil, + wantErr: true, + }, + { + name: "add link", + args: args{ + CTX, + &user.AddIDPLinkRequest{ + UserId: Tester.Users[integration.OrgOwner].ID, + IdpLink: &user.IDPLink{ + IdpId: idpID, + IdpExternalId: "externalID", + DisplayName: "displayName", + }, + }, + }, + want: &user.AddIDPLinkResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.AddIDPLink(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_StartIdentityProviderFlow(t *testing.T) { + idpID := createProvider(t) + type args struct { + ctx context.Context + req *user.StartIdentityProviderFlowRequest + } + tests := []struct { + name string + args args + want *user.StartIdentityProviderFlowResponse + wantErr bool + }{ + { + name: "missing urls", + args: args{ + CTX, + &user.StartIdentityProviderFlowRequest{ + IdpId: idpID, + }, + }, + want: nil, + wantErr: true, + }, + { + name: "next step auth url", + args: args{ + CTX, + &user.StartIdentityProviderFlowRequest{ + IdpId: idpID, + SuccessUrl: "https://example.com/success", + FailureUrl: "https://example.com/failure", + }, + }, + want: &user.StartIdentityProviderFlowResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + NextStep: &user.StartIdentityProviderFlowResponse_AuthUrl{ + AuthUrl: "https://example.com/oauth/v2/authorize?client_id=clientID&prompt=select_account&redirect_uri=https%3A%2F%2Flocalhost%3A8080%2Fidps%2Fcallback&response_type=code&scope=openid+profile+email&state=", + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.StartIdentityProviderFlow(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + if nextStep := tt.want.GetNextStep(); nextStep != nil { + if !strings.HasPrefix(got.GetAuthUrl(), tt.want.GetAuthUrl()) { + assert.Failf(t, "auth url does not match", "expected: %s, but got: %s", tt.want.GetAuthUrl(), got.GetAuthUrl()) + } + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_RetrieveIdentityProviderInformation(t *testing.T) { + idpID := createProvider(t) + intentID := createIntent(t, idpID) + successfulID, token, changeDate, sequence := createSuccessfulIntent(t, idpID) + type args struct { + ctx context.Context + req *user.RetrieveIdentityProviderInformationRequest + } + tests := []struct { + name string + args args + want *user.RetrieveIdentityProviderInformationResponse + wantErr bool + }{ + { + name: "failed intent", + args: args{ + CTX, + &user.RetrieveIdentityProviderInformationRequest{ + IntentId: intentID, + Token: "", + }, + }, + wantErr: true, + }, + { + name: "wrong token", + args: args{ + CTX, + &user.RetrieveIdentityProviderInformationRequest{ + IntentId: successfulID, + Token: "wrong token", + }, + }, + wantErr: true, + }, + { + name: "retrieve successful intent", + args: args{ + CTX, + &user.RetrieveIdentityProviderInformationRequest{ + IntentId: successfulID, + Token: token, + }, + }, + want: &user.RetrieveIdentityProviderInformationResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.New(changeDate), + ResourceOwner: Tester.Organisation.ID, + Sequence: sequence, + }, + IdpInformation: &user.IDPInformation{ + Access: &user.IDPInformation_Oauth{ + Oauth: &user.IDPOAuthAccessInformation{ + AccessToken: "accessToken", + IdToken: gu.Ptr("idToken"), + }, + }, + IdpInformation: []byte(`{"RawInfo":{"id":"id"}}`), + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.RetrieveIdentityProviderInformation(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + require.Equal(t, tt.want.GetDetails(), got.GetDetails()) + require.Equal(t, tt.want.GetIdpInformation(), got.GetIdpInformation()) + }) + } +} diff --git a/internal/api/grpc/user/v2/user_test.go b/internal/api/grpc/user/v2/user_test.go index d697e9ae4..044099a89 100644 --- a/internal/api/grpc/user/v2/user_test.go +++ b/internal/api/grpc/user/v2/user_test.go @@ -3,11 +3,21 @@ package user import ( "errors" "testing" + "time" + "github.com/golang/mock/gomock" + "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/api/grpc" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" caos_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" ) @@ -78,3 +88,118 @@ func Test_hashedPasswordToCommand(t *testing.T) { }) } } + +func Test_intentToIDPInformationPb(t *testing.T) { + decryption := func(err error) crypto.EncryptionAlgorithm { + mCrypto := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) + mCrypto.EXPECT().Algorithm().Return("enc") + mCrypto.EXPECT().DecryptionKeyIDs().Return([]string{"id"}) + mCrypto.EXPECT().DecryptString(gomock.Any(), gomock.Any()).DoAndReturn( + func(code []byte, keyID string) (string, error) { + if err != nil { + return "", err + } + return string(code), nil + }) + return mCrypto + } + + type args struct { + intent *command.IDPIntentWriteModel + alg crypto.EncryptionAlgorithm + } + type res struct { + resp *user.RetrieveIdentityProviderInformationResponse + err error + } + tests := []struct { + name string + args args + res res + }{ + { + "decryption invalid key id error", + args{ + intent: &command.IDPIntentWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "intentID", + ProcessedSequence: 123, + ResourceOwner: "ro", + InstanceID: "instanceID", + ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local), + }, + IDPID: "idpID", + IDPUser: []byte(`{"id": "id"}`), + IDPAccessToken: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("accessToken"), + }, + IDPIDToken: "idToken", + UserID: "userID", + State: domain.IDPIntentStateSucceeded, + }, + alg: decryption(caos_errs.ThrowInternal(nil, "id", "invalid key id")), + }, + res{ + resp: nil, + err: caos_errs.ThrowInternal(nil, "id", "invalid key id"), + }, + }, + { + "successful", + args{ + intent: &command.IDPIntentWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "intentID", + ProcessedSequence: 123, + ResourceOwner: "ro", + InstanceID: "instanceID", + ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local), + }, + IDPID: "idpID", + IDPUser: []byte(`{"id": "id"}`), + IDPAccessToken: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("accessToken"), + }, + IDPIDToken: "idToken", + UserID: "userID", + State: domain.IDPIntentStateSucceeded, + }, + alg: decryption(nil), + }, + res{ + resp: &user.RetrieveIdentityProviderInformationResponse{ + Details: &object_pb.Details{ + Sequence: 123, + ChangeDate: timestamppb.New(time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local)), + ResourceOwner: "ro", + }, + IdpInformation: &user.IDPInformation{ + Access: &user.IDPInformation_Oauth{ + Oauth: &user.IDPOAuthAccessInformation{ + AccessToken: "accessToken", + IdToken: gu.Ptr("idToken"), + }}, + IdpInformation: []byte(`{"id": "id"}`), + }, + }, + err: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := intentToIDPInformationPb(tt.args.intent, tt.args.alg) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.resp, got) + if tt.res.resp != nil { + grpc.AllFieldsSet(t, got.ProtoReflect()) + } + }) + } +} diff --git a/internal/api/idp/idp.go b/internal/api/idp/idp.go new file mode 100644 index 000000000..37a84f526 --- /dev/null +++ b/internal/api/idp/idp.go @@ -0,0 +1,246 @@ +package idp + +import ( + "context" + "errors" + "net/http" + + "github.com/gorilla/mux" + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + http_utils "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" + z_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/form" + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/azuread" + "github.com/zitadel/zitadel/internal/idp/providers/github" + "github.com/zitadel/zitadel/internal/idp/providers/gitlab" + "github.com/zitadel/zitadel/internal/idp/providers/google" + "github.com/zitadel/zitadel/internal/idp/providers/jwt" + "github.com/zitadel/zitadel/internal/idp/providers/ldap" + "github.com/zitadel/zitadel/internal/idp/providers/oauth" + openid "github.com/zitadel/zitadel/internal/idp/providers/oidc" + "github.com/zitadel/zitadel/internal/query" +) + +const ( + HandlerPrefix = "/idps" + callbackPath = "/callback" + + paramIntentID = "id" + paramToken = "token" + paramUserID = "user" + paramError = "error" + paramErrorDescription = "error_description" +) + +type Handler struct { + commands *command.Commands + queries *query.Queries + parser *form.Parser + encryptionAlgorithm crypto.EncryptionAlgorithm + callbackURL func(ctx context.Context) string +} + +type externalIDPCallbackData struct { + State string `schema:"state"` + Code string `schema:"code"` + Error string `schema:"error"` + ErrorDescription string `schema:"error_description"` +} + +// CallbackURL generates the instance specific URL to the IDP callback handler +func CallbackURL(externalSecure bool) func(ctx context.Context) string { + return func(ctx context.Context) string { + return http_utils.BuildOrigin(authz.GetInstance(ctx).RequestedHost(), externalSecure) + HandlerPrefix + callbackPath + } +} + +func NewHandler( + commands *command.Commands, + queries *query.Queries, + encryptionAlgorithm crypto.EncryptionAlgorithm, + externalSecure bool, + instanceInterceptor func(next http.Handler) http.Handler, +) http.Handler { + h := &Handler{ + commands: commands, + queries: queries, + parser: form.NewParser(), + encryptionAlgorithm: encryptionAlgorithm, + callbackURL: CallbackURL(externalSecure), + } + + router := mux.NewRouter() + router.Use(instanceInterceptor) + router.HandleFunc(callbackPath, h.handleCallback) + return router +} + +func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) { + data, err := h.parseCallbackRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + intent := h.getActiveIntent(w, r, data.State) + if intent == nil { + // if we didn't get an active intent the error was already handled (either redirected or display directly) + return + } + + ctx := r.Context() + // the provider might have returned an error + if data.Error != "" { + cmdErr := h.commands.FailIDPIntent(ctx, intent, reason(data.Error, data.ErrorDescription)) + logging.WithFields("intent", intent.AggregateID).OnError(cmdErr).Error("failed to push failed event on idp intent") + redirectToFailureURL(w, r, intent, data.Error, data.ErrorDescription) + return + } + + provider, err := h.commands.GetProvider(ctx, intent.IDPID, h.callbackURL(ctx)) + if err != nil { + cmdErr := h.commands.FailIDPIntent(ctx, intent, err.Error()) + logging.WithFields("intent", intent.AggregateID).OnError(cmdErr).Error("failed to push failed event on idp intent") + redirectToFailureURLErr(w, r, intent, err) + return + } + + idpUser, idpSession, err := h.fetchIDPUser(ctx, provider, data.Code) + if err != nil { + cmdErr := h.commands.FailIDPIntent(ctx, intent, err.Error()) + logging.WithFields("intent", intent.AggregateID).OnError(cmdErr).Error("failed to push failed event on idp intent") + redirectToFailureURLErr(w, r, intent, err) + return + } + userID, err := h.checkExternalUser(ctx, intent.IDPID, idpUser.GetID()) + logging.WithFields("intent", intent.AggregateID).OnError(err).Error("could not check if idp user already exists") + + token, err := h.commands.SucceedIDPIntent(ctx, intent, idpUser, idpSession, userID) + if err != nil { + redirectToFailureURLErr(w, r, intent, z_errs.ThrowInternal(err, "IDP-JdD3g", "Errors.Intent.TokenCreationFailed")) + return + } + redirectToSuccessURL(w, r, intent, token, userID) +} + +func (h *Handler) parseCallbackRequest(r *http.Request) (*externalIDPCallbackData, error) { + data := new(externalIDPCallbackData) + err := h.parser.Parse(r, data) + if err != nil { + return nil, err + } + if data.State == "" { + return nil, z_errs.ThrowInvalidArgument(nil, "IDP-Hk38e", "Errors.Intent.StateMissing") + } + return data, nil +} + +func (h *Handler) getActiveIntent(w http.ResponseWriter, r *http.Request, state string) *command.IDPIntentWriteModel { + intent, err := h.commands.GetIntentWriteModel(r.Context(), state, "") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return nil + } + if intent.State == domain.IDPIntentStateUnspecified { + http.Error(w, reason("IDP-Hk38e", "Errors.Intent.NotStarted"), http.StatusBadRequest) + return nil + } + if intent.State != domain.IDPIntentStateStarted { + redirectToFailureURL(w, r, intent, "IDP-Sfrgs", "Errors.Intent.NotStarted") + return nil + } + return intent +} + +func redirectToSuccessURL(w http.ResponseWriter, r *http.Request, intent *command.IDPIntentWriteModel, token, userID string) { + queries := intent.SuccessURL.Query() + queries.Set(paramIntentID, intent.AggregateID) + queries.Set(paramToken, token) + if userID != "" { + queries.Set(paramUserID, userID) + } + intent.SuccessURL.RawQuery = queries.Encode() + http.Redirect(w, r, intent.SuccessURL.String(), http.StatusFound) +} + +func redirectToFailureURLErr(w http.ResponseWriter, r *http.Request, i *command.IDPIntentWriteModel, err error) { + msg := err.Error() + var description string + zErr := new(z_errs.CaosError) + if errors.As(err, &zErr) { + msg = zErr.GetID() + description = zErr.GetMessage() // TODO: i18n? + } + redirectToFailureURL(w, r, i, msg, description) +} + +func redirectToFailureURL(w http.ResponseWriter, r *http.Request, i *command.IDPIntentWriteModel, err, description string) { + queries := i.FailureURL.Query() + queries.Set(paramIntentID, i.AggregateID) + queries.Set(paramError, err) + queries.Set(paramErrorDescription, description) + i.FailureURL.RawQuery = queries.Encode() + http.Redirect(w, r, i.FailureURL.String(), http.StatusFound) +} + +func (h *Handler) fetchIDPUser(ctx context.Context, identityProvider idp.Provider, code string) (user idp.User, idpTokens idp.Session, err error) { + var session idp.Session + switch provider := identityProvider.(type) { + case *oauth.Provider: + session = &oauth.Session{Provider: provider, Code: code} + case *openid.Provider: + session = &openid.Session{Provider: provider, Code: code} + case *azuread.Provider: + session = &oauth.Session{Provider: provider.Provider, Code: code} + case *github.Provider: + session = &oauth.Session{Provider: provider.Provider, Code: code} + case *gitlab.Provider: + session = &openid.Session{Provider: provider.Provider, Code: code} + case *google.Provider: + session = &openid.Session{Provider: provider.Provider, Code: code} + case *jwt.Provider, *ldap.Provider: + return nil, nil, z_errs.ThrowInvalidArgument(nil, "IDP-52jmn", "Errors.ExternalIDP.IDPTypeNotImplemented") + default: + return nil, nil, z_errs.ThrowUnimplemented(nil, "IDP-SSDg", "Errors.ExternalIDP.IDPTypeNotImplemented") + } + + user, err = session.FetchUser(ctx) + if err != nil { + return nil, nil, err + } + return user, session, nil +} + +func (h *Handler) checkExternalUser(ctx context.Context, idpID, externalUserID string) (userID string, err error) { + idQuery, err := query.NewIDPUserLinkIDPIDSearchQuery(idpID) + if err != nil { + return "", err + } + externalIDQuery, err := query.NewIDPUserLinksExternalIDSearchQuery(externalUserID) + if err != nil { + return "", err + } + queries := []query.SearchQuery{ + idQuery, externalIDQuery, + } + links, err := h.queries.IDPUserLinks(ctx, &query.IDPUserLinksSearchQuery{Queries: queries}, false) + if err != nil { + return "", err + } + if len(links.Links) != 1 { + return "", nil + } + return links.Links[0].UserID, nil +} + +func reason(err, description string) string { + if description == "" { + return err + } + return err + ": " + description +} diff --git a/internal/api/idp/idp_test.go b/internal/api/idp/idp_test.go new file mode 100644 index 000000000..1e6395197 --- /dev/null +++ b/internal/api/idp/idp_test.go @@ -0,0 +1,220 @@ +package idp + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/command" + z_errors "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/form" +) + +func Test_redirectToSuccessURL(t *testing.T) { + type args struct { + id string + userID string + token string + failureURL string + successURL string + } + type res struct { + want string + } + tests := []struct { + name string + args args + res res + }{ + { + "redirect", + args{ + id: "id", + token: "token", + failureURL: "https://example.com/failure", + successURL: "https://example.com/success", + }, + res{ + "https://example.com/success?id=id&token=token", + }, + }, + { + "redirect with userID", + args{ + id: "id", + userID: "user", + token: "token", + failureURL: "https://example.com/failure", + successURL: "https://example.com/success", + }, + res{ + "https://example.com/success?id=id&token=token&user=user", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "http://example.com", nil) + resp := httptest.NewRecorder() + + wm := command.NewIDPIntentWriteModel(tt.args.id, tt.args.id) + wm.FailureURL, _ = url.Parse(tt.args.failureURL) + wm.SuccessURL, _ = url.Parse(tt.args.successURL) + + redirectToSuccessURL(resp, req, wm, tt.args.token, tt.args.userID) + assert.Equal(t, tt.res.want, resp.Header().Get("Location")) + }) + } +} + +func Test_redirectToFailureURL(t *testing.T) { + type args struct { + id string + failureURL string + successURL string + err string + desc string + } + type res struct { + want string + } + tests := []struct { + name string + args args + res res + }{ + { + "redirect", + args{ + id: "id", + failureURL: "https://example.com/failure", + successURL: "https://example.com/success", + }, + res{ + "https://example.com/failure?error=&error_description=&id=id", + }, + }, + { + "redirect with error", + args{ + id: "id", + failureURL: "https://example.com/failure", + successURL: "https://example.com/success", + err: "test", + desc: "testdesc", + }, + res{ + "https://example.com/failure?error=test&error_description=testdesc&id=id", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "http://example.com", nil) + resp := httptest.NewRecorder() + + wm := command.NewIDPIntentWriteModel(tt.args.id, tt.args.id) + wm.FailureURL, _ = url.Parse(tt.args.failureURL) + wm.SuccessURL, _ = url.Parse(tt.args.successURL) + + redirectToFailureURL(resp, req, wm, tt.args.err, tt.args.desc) + assert.Equal(t, tt.res.want, resp.Header().Get("Location")) + }) + } +} + +func Test_redirectToFailureURLErr(t *testing.T) { + type args struct { + id string + failureURL string + successURL string + err error + } + type res struct { + want string + } + tests := []struct { + name string + args args + res res + }{ + { + "redirect with error", + args{ + id: "id", + failureURL: "https://example.com/failure", + successURL: "https://example.com/success", + err: z_errors.ThrowError(nil, "test", "testdesc"), + }, + res{ + "https://example.com/failure?error=test&error_description=testdesc&id=id", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "http://example.com", nil) + resp := httptest.NewRecorder() + + wm := command.NewIDPIntentWriteModel(tt.args.id, tt.args.id) + wm.FailureURL, _ = url.Parse(tt.args.failureURL) + wm.SuccessURL, _ = url.Parse(tt.args.successURL) + + redirectToFailureURLErr(resp, req, wm, tt.args.err) + assert.Equal(t, tt.res.want, resp.Header().Get("Location")) + }) + } +} + +func Test_parseCallbackRequest(t *testing.T) { + type args struct { + url string + } + type res struct { + want *externalIDPCallbackData + err bool + } + tests := []struct { + name string + args args + res res + }{ + { + "no state", + args{ + url: "https://example.com?state=&code=code&error=error&error_description=desc", + }, + res{ + err: true, + }, + }, + { + "parse", + args{ + url: "https://example.com?state=state&code=code&error=error&error_description=desc", + }, + res{ + want: &externalIDPCallbackData{ + State: "state", + Code: "code", + Error: "error", + ErrorDescription: "desc", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.args.url, nil) + handler := Handler{parser: form.NewParser()} + + data, err := handler.parseCallbackRequest(req) + if tt.res.err { + assert.Error(t, err) + } + assert.Equal(t, tt.res.want, data) + }) + } +} diff --git a/internal/command/command.go b/internal/command/command.go index b87b2fc45..3c804576b 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -15,6 +15,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/repository/action" + "github.com/zitadel/zitadel/internal/repository/idpintent" instance_repo "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/keypair" "github.com/zitadel/zitadel/internal/repository/org" @@ -122,6 +123,7 @@ func StartCommands( action.RegisterEventMappers(repo.eventstore) quota.RegisterEventMappers(repo.eventstore) session.RegisterEventMappers(repo.eventstore) + idpintent.RegisterEventMappers(repo.eventstore) repo.userPasswordAlg = crypto.NewBCrypt(defaults.SecretGenerators.PasswordSaltCost) repo.machineKeySize = int(defaults.SecretGenerators.MachineKeySize) diff --git a/internal/command/idp.go b/internal/command/idp.go index a5605e8a3..4e34aa6e4 100644 --- a/internal/command/idp.go +++ b/internal/command/idp.go @@ -6,6 +6,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" + "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/repository/idp" ) @@ -139,3 +140,37 @@ func ExistsIDP(ctx context.Context, filter preparation.FilterToQueryReducer, id, } return instanceWriteModel.State.Exists(), nil } + +func IDPProviderWriteModel(ctx context.Context, filter preparation.FilterToQueryReducer, id string) (_ *AllIDPWriteModel, err error) { + writeModel := NewIDPTypeWriteModel(id) + events, err := filter(ctx, writeModel.Query()) + if err != nil { + return nil, err + } + if len(events) == 0 { + return nil, errors.ThrowPreconditionFailed(nil, "COMMAND-as02jin", "Errors.IDPConfig.NotExisting") + } + writeModel.AppendEvents(events...) + if err := writeModel.Reduce(); err != nil { + return nil, err + } + allWriteModel, err := NewAllIDPWriteModel( + writeModel.ResourceOwner, + writeModel.ResourceOwner == writeModel.InstanceID, + writeModel.ID, + writeModel.Type, + ) + if err != nil { + return nil, err + } + events, err = filter(ctx, allWriteModel.Query()) + if err != nil { + return nil, err + } + allWriteModel.AppendEvents(events...) + if err := allWriteModel.Reduce(); err != nil { + return nil, err + } + + return allWriteModel, err +} diff --git a/internal/command/idp_intent.go b/internal/command/idp_intent.go new file mode 100644 index 000000000..1045b7701 --- /dev/null +++ b/internal/command/idp_intent.go @@ -0,0 +1,177 @@ +package command + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/url" + + "github.com/zitadel/oidc/v2/pkg/oidc" + + "github.com/zitadel/zitadel/internal/command/preparation" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/jwt" + "github.com/zitadel/zitadel/internal/idp/providers/oauth" + openid "github.com/zitadel/zitadel/internal/idp/providers/oidc" + "github.com/zitadel/zitadel/internal/repository/idpintent" +) + +func (c *Commands) prepareCreateIntent(writeModel *IDPIntentWriteModel, idpID string, successURL, failureURL string) preparation.Validation { + return func() (_ preparation.CreateCommands, err error) { + if idpID == "" { + return nil, errors.ThrowInvalidArgument(nil, "COMMAND-x8j2bk", "Errors.Intent.IDPMissing") + } + successURL, err := url.Parse(successURL) + if err != nil { + return nil, errors.ThrowInvalidArgument(nil, "COMMAND-x8j3bk", "Errors.Intent.SuccessURLMissing") + } + failureURL, err := url.Parse(failureURL) + if err != nil { + return nil, errors.ThrowInvalidArgument(nil, "COMMAND-x8j4bk", "Errors.Intent.FailureURLMissing") + } + return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { + err = getIDPIntentWriteModel(ctx, writeModel, filter) + if err != nil { + return nil, err + } + exists, err := ExistsIDP(ctx, filter, idpID, writeModel.ResourceOwner) + if !exists || err != nil { + return nil, errors.ThrowPreconditionFailed(err, "COMMAND-39n221fs", "Errors.IDPConfig.NotExisting") + } + return []eventstore.Command{ + idpintent.NewStartedEvent(ctx, writeModel.aggregate, successURL, failureURL, idpID), + }, nil + }, nil + } +} + +func (c *Commands) CreateIntent(ctx context.Context, idpID, successURL, failureURL, resourceOwner string) (string, *domain.ObjectDetails, error) { + id, err := c.idGenerator.Next() + if err != nil { + return "", nil, err + } + writeModel := NewIDPIntentWriteModel(id, resourceOwner) + if err != nil { + return "", nil, err + } + + cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareCreateIntent(writeModel, idpID, successURL, failureURL)) + if err != nil { + return "", nil, err + } + pushedEvents, err := c.eventstore.Push(ctx, cmds...) + if err != nil { + return "", nil, err + } + err = AppendAndReduce(writeModel, pushedEvents...) + if err != nil { + return "", nil, err + } + return id, writeModelToObjectDetails(&writeModel.WriteModel), nil +} + +func (c *Commands) GetProvider(ctx context.Context, idpID, callbackURL string) (idp.Provider, error) { + writeModel, err := IDPProviderWriteModel(ctx, c.eventstore.Filter, idpID) + if err != nil { + return nil, err + } + return writeModel.ToProvider(callbackURL, c.idpConfigEncryption) +} + +func (c *Commands) AuthURLFromProvider(ctx context.Context, idpID, state, callbackURL string) (string, error) { + provider, err := c.GetProvider(ctx, idpID, callbackURL) + if err != nil { + return "", err + } + session, err := provider.BeginAuth(ctx, state) + if err != nil { + return "", err + } + return session.GetAuthURL(), nil +} + +func getIDPIntentWriteModel(ctx context.Context, writeModel *IDPIntentWriteModel, filter preparation.FilterToQueryReducer) error { + events, err := filter(ctx, writeModel.Query()) + if err != nil { + return err + } + if len(events) == 0 { + return nil + } + writeModel.AppendEvents(events...) + return writeModel.Reduce() +} + +func (c *Commands) SucceedIDPIntent(ctx context.Context, writeModel *IDPIntentWriteModel, idpUser idp.User, idpSession idp.Session, userID string) (string, error) { + token, err := c.idpConfigEncryption.Encrypt([]byte(writeModel.AggregateID)) + if err != nil { + return "", err + } + accessToken, idToken, err := tokensForSucceededIDPIntent(idpSession, c.idpConfigEncryption) + if err != nil { + return "", err + } + idpInfo, err := json.Marshal(idpUser) + if err != nil { + return "", err + } + cmd, err := idpintent.NewSucceededEvent( + ctx, + &idpintent.NewAggregate(writeModel.AggregateID, writeModel.ResourceOwner).Aggregate, + idpInfo, + userID, + accessToken, + idToken, + ) + if err != nil { + return "", err + } + err = c.pushAppendAndReduce(ctx, writeModel, cmd) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(token), nil +} + +func (c *Commands) FailIDPIntent(ctx context.Context, writeModel *IDPIntentWriteModel, reason string) error { + cmd := idpintent.NewFailedEvent( + ctx, + &idpintent.NewAggregate(writeModel.AggregateID, writeModel.ResourceOwner).Aggregate, + reason, + ) + _, err := c.eventstore.Push(ctx, cmd) + return err +} + +func (c *Commands) GetIntentWriteModel(ctx context.Context, id, resourceOwner string) (*IDPIntentWriteModel, error) { + writeModel := NewIDPIntentWriteModel(id, resourceOwner) + err := c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, err +} + +// tokensForSucceededIDPIntent extracts the oidc.Tokens if available (and encrypts the access_token) for the succeeded event payload +func tokensForSucceededIDPIntent(session idp.Session, encryptionAlg crypto.EncryptionAlgorithm) (*crypto.CryptoValue, string, error) { + var tokens *oidc.Tokens[*oidc.IDTokenClaims] + switch s := session.(type) { + case *oauth.Session: + tokens = s.Tokens + case *openid.Session: + tokens = s.Tokens + case *jwt.Session: + tokens = s.Tokens + default: + return nil, "", nil + } + if tokens.Token == nil || tokens.AccessToken == "" { + return nil, tokens.IDToken, nil + } + accessToken, err := crypto.Encrypt([]byte(tokens.AccessToken), encryptionAlg) + return accessToken, tokens.IDToken, err +} diff --git a/internal/command/idp_intent_model.go b/internal/command/idp_intent_model.go new file mode 100644 index 000000000..cd0cb6e8e --- /dev/null +++ b/internal/command/idp_intent_model.go @@ -0,0 +1,82 @@ +package command + +import ( + "net/url" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/idpintent" +) + +type IDPIntentWriteModel struct { + eventstore.WriteModel + + SuccessURL *url.URL + FailureURL *url.URL + IDPID string + IDPUser []byte + IDPAccessToken *crypto.CryptoValue + IDPIDToken string + UserID string + + State domain.IDPIntentState + aggregate *eventstore.Aggregate +} + +func NewIDPIntentWriteModel(id, resourceOwner string) *IDPIntentWriteModel { + return &IDPIntentWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: id, + ResourceOwner: resourceOwner, + }, + aggregate: &idpintent.NewAggregate(id, resourceOwner).Aggregate, + } +} + +func (wm *IDPIntentWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *idpintent.StartedEvent: + wm.reduceStartedEvent(e) + case *idpintent.SucceededEvent: + wm.reduceSucceededEvent(e) + case *idpintent.FailedEvent: + wm.reduceFailedEvent(e) + } + } + return wm.WriteModel.Reduce() +} + +func (wm *IDPIntentWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). + AddQuery(). + AggregateTypes(idpintent.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + idpintent.StartedEventType, + idpintent.SucceededEventType, + idpintent.FailedEventType, + ). + Builder() +} + +func (wm *IDPIntentWriteModel) reduceStartedEvent(e *idpintent.StartedEvent) { + wm.SuccessURL = e.SuccessURL + wm.FailureURL = e.FailureURL + wm.IDPID = e.IDPID + wm.State = domain.IDPIntentStateStarted +} + +func (wm *IDPIntentWriteModel) reduceSucceededEvent(e *idpintent.SucceededEvent) { + wm.UserID = e.UserID + wm.IDPUser = e.IDPUser + wm.IDPAccessToken = e.IDPAccessToken + wm.IDPIDToken = e.IDPIDToken + wm.State = domain.IDPIntentStateSucceeded +} + +func (wm *IDPIntentWriteModel) reduceFailedEvent(e *idpintent.FailedEvent) { + wm.State = domain.IDPIntentStateFailed +} diff --git a/internal/command/idp_intent_test.go b/internal/command/idp_intent_test.go new file mode 100644 index 000000000..a0be850fb --- /dev/null +++ b/internal/command/idp_intent_test.go @@ -0,0 +1,667 @@ +package command + +import ( + "context" + "net/url" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/pkg/oidc" + "golang.org/x/oauth2" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + z_errors "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/jwt" + "github.com/zitadel/zitadel/internal/idp/providers/ldap" + "github.com/zitadel/zitadel/internal/idp/providers/oauth" + openid "github.com/zitadel/zitadel/internal/idp/providers/oidc" + rep_idp "github.com/zitadel/zitadel/internal/repository/idp" + "github.com/zitadel/zitadel/internal/repository/idpintent" + "github.com/zitadel/zitadel/internal/repository/instance" +) + +func TestCommands_CreateIntent(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + idGenerator id.Generator + } + type args struct { + ctx context.Context + idpID string + successURL string + failureURL string + resourceOwner string + } + type res struct { + intentID string + details *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "error no id generator", + fields{ + eventstore: eventstoreExpect(t), + idGenerator: mock.NewIDGeneratorExpectError(t, z_errors.ThrowInternal(nil, "", "error id")), + }, + args{ + ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), + idpID: "", + successURL: "https://success.url", + failureURL: "https://failure.url", + }, + res{ + err: z_errors.ThrowInternal(nil, "", "error id"), + }, + }, + { + "error no idpID", + fields{ + eventstore: eventstoreExpect(t), + idGenerator: mock.ExpectID(t, "id"), + }, + args{ + ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), + idpID: "", + successURL: "https://success.url", + failureURL: "https://failure.url", + }, + res{ + err: z_errors.ThrowInvalidArgument(nil, "COMMAND-x8j2bk", "Errors.Intent.IDPMissing"), + }, + }, + { + "error no successURL", + fields{ + eventstore: eventstoreExpect(t), + idGenerator: mock.ExpectID(t, "id"), + }, + args{ + ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), + idpID: "idp", + successURL: ":", + failureURL: "https://failure.url", + }, + res{ + err: z_errors.ThrowInvalidArgument(nil, "COMMAND-x8j3bk", "Errors.Intent.SuccessURLMissing"), + }, + }, + { + "error no failureURL", + fields{ + eventstore: eventstoreExpect(t), + idGenerator: mock.ExpectID(t, "id"), + }, + args{ + ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), + idpID: "idp", + successURL: "https://success.url", + failureURL: ":", + }, + res{ + err: z_errors.ThrowInvalidArgument(nil, "COMMAND-x8j4bk", "Errors.Intent.FailureURLMissing"), + }, + }, + { + "error idp not existing", + fields{ + eventstore: eventstoreExpect(t, + expectFilter(), + expectFilter(), + expectFilter(), + ), + idGenerator: mock.ExpectID(t, "id"), + }, + args{ + ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), + idpID: "idp", + successURL: "https://success.url", + failureURL: "https://failure.url", + }, + res{ + err: z_errors.ThrowPreconditionFailed(nil, "COMMAND-39n221fs", "Errors.IDPConfig.NotExisting"), + }, + }, + { + "push", + fields{ + eventstore: eventstoreExpect(t, + expectFilter(), + expectFilter(), + expectFilter( + eventFromEventPusher( + instance.NewOAuthIDPAddedEvent(context.Background(), &instance.NewAggregate("ro").Aggregate, + "idp", + "name", + "clientID", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("clientSecret"), + }, + "auth", + "token", + "user", + "idAttribute", + nil, + rep_idp.Options{}, + )), + ), + expectPush( + eventPusherToEvents( + func() eventstore.Command { + success, _ := url.Parse("https://success.url") + failure, _ := url.Parse("https://failure.url") + return idpintent.NewStartedEvent( + context.Background(), + &idpintent.NewAggregate("id", "ro").Aggregate, + success, + failure, + "idp", + ) + }(), + ), + ), + ), + idGenerator: mock.ExpectID(t, "id"), + }, + args{ + ctx: context.Background(), + resourceOwner: "ro", + idpID: "idp", + successURL: "https://success.url", + failureURL: "https://failure.url", + }, + res{ + intentID: "id", + details: &domain.ObjectDetails{ResourceOwner: "ro"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + idGenerator: tt.fields.idGenerator, + } + intentID, details, err := c.CreateIntent(tt.args.ctx, tt.args.idpID, tt.args.successURL, tt.args.failureURL, tt.args.resourceOwner) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.intentID, intentID) + assert.Equal(t, tt.res.details, details) + }) + } +} + +func TestCommands_AuthURLFromProvider(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + secretCrypto crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + idpID string + state string + callbackURL string + } + type res struct { + authURL string + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "idp not existing", + fields{ + secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + eventstore: eventstoreExpect(t, + expectFilter(), + ), + }, + args{ + ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), + idpID: "idp", + state: "state", + callbackURL: "url", + }, + res{ + err: z_errors.ThrowPreconditionFailed(nil, "", ""), + }, + }, + { + "idp removed", + fields{ + secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusherWithInstanceID( + "instance", + instance.NewOAuthIDPAddedEvent(context.Background(), &instance.NewAggregate("instance").Aggregate, + "idp", + "name", + "clientID", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("clientSecret"), + }, + "auth", + "token", + "user", + "idAttribute", + nil, + rep_idp.Options{}, + )), + eventFromEventPusherWithInstanceID( + "instance", + instance.NewIDPRemovedEvent(context.Background(), &instance.NewAggregate("instance").Aggregate, + "idp", + ), + ), + ), + ), + }, + args{ + ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), + idpID: "idp", + state: "state", + callbackURL: "url", + }, + res{ + err: z_errors.ThrowInternal(nil, "COMMAND-xw921211", "Errors.IDPConfig.NotExisting"), + }, + }, + { + "push", + fields{ + secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusherWithInstanceID( + "instance", + instance.NewOAuthIDPAddedEvent(context.Background(), &instance.NewAggregate("instance").Aggregate, + "idp", + "name", + "clientID", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("clientSecret"), + }, + "auth", + "token", + "user", + "idAttribute", + nil, + rep_idp.Options{}, + )), + ), + expectFilter( + eventFromEventPusherWithInstanceID( + "instance", + instance.NewOAuthIDPAddedEvent(context.Background(), &instance.NewAggregate("instance").Aggregate, + "idp", + "name", + "clientID", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("clientSecret"), + }, + "auth", + "token", + "user", + "idAttribute", + nil, + rep_idp.Options{}, + )), + ), + ), + }, + args{ + ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), + idpID: "idp", + state: "state", + callbackURL: "url", + }, + res{ + authURL: "auth?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&state=state", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + idpConfigEncryption: tt.fields.secretCrypto, + } + authURL, err := c.AuthURLFromProvider(tt.args.ctx, tt.args.idpID, tt.args.state, tt.args.callbackURL) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.authURL, authURL) + }) + } +} + +func TestCommands_SucceedIDPIntent(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + idpConfigEncryption crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + writeModel *IDPIntentWriteModel + idpUser idp.User + idpSession idp.Session + userID string + } + type res struct { + token string + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "encryption fails", + fields{ + idpConfigEncryption: func() crypto.EncryptionAlgorithm { + m := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) + m.EXPECT().Encrypt(gomock.Any()).Return(nil, z_errors.ThrowInternal(nil, "id", "encryption failed")) + return m + }(), + }, + args{ + ctx: context.Background(), + writeModel: NewIDPIntentWriteModel("id", "ro"), + }, + res{ + err: z_errors.ThrowInternal(nil, "id", "encryption failed"), + }, + }, + { + "token encryption fails", + fields{ + idpConfigEncryption: func() crypto.EncryptionAlgorithm { + m := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) + m.EXPECT().Encrypt(gomock.Any()).DoAndReturn(func(value []byte) ([]byte, error) { + return value, nil + }) + m.EXPECT().Encrypt(gomock.Any()).Return(nil, z_errors.ThrowInternal(nil, "id", "encryption failed")) + return m + }(), + }, + args{ + ctx: context.Background(), + writeModel: NewIDPIntentWriteModel("id", "ro"), + idpSession: &oauth.Session{ + Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + }, + }, + }, + }, + res{ + err: z_errors.ThrowInternal(nil, "id", "encryption failed"), + }, + }, + { + "push", + fields{ + idpConfigEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + eventstore: eventstoreExpect(t, + expectPush( + eventPusherToEvents( + func() eventstore.Command { + event, _ := idpintent.NewSucceededEvent( + context.Background(), + &idpintent.NewAggregate("id", "ro").Aggregate, + []byte(`{"RawInfo":{"id":"id"}}`), + "", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("accessToken"), + }, + "", + ) + return event + }(), + ), + ), + ), + }, + args{ + ctx: context.Background(), + writeModel: NewIDPIntentWriteModel("id", "ro"), + idpSession: &oauth.Session{ + Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + }, + }, + }, + idpUser: &oauth.UserMapper{ + RawInfo: map[string]interface{}{ + "id": "id", + }, + }, + }, + res{ + token: "aWQ", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + idpConfigEncryption: tt.fields.idpConfigEncryption, + } + got, err := c.SucceedIDPIntent(tt.args.ctx, tt.args.writeModel, tt.args.idpUser, tt.args.idpSession, tt.args.userID) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.token, got) + }) + } +} + +func TestCommands_FailIDPIntent(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + } + type args struct { + ctx context.Context + writeModel *IDPIntentWriteModel + reason string + } + type res struct { + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "push", + fields{ + eventstore: eventstoreExpect(t, + expectPush( + eventPusherToEvents( + idpintent.NewFailedEvent( + context.Background(), + &idpintent.NewAggregate("id", "ro").Aggregate, + "reason", + ), + ), + ), + ), + }, + args{ + ctx: context.Background(), + writeModel: NewIDPIntentWriteModel("id", "ro"), + reason: "reason", + }, + res{ + err: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + } + err := c.FailIDPIntent(tt.args.ctx, tt.args.writeModel, tt.args.reason) + require.ErrorIs(t, err, tt.res.err) + }) + } +} + +func Test_tokensForSucceededIDPIntent(t *testing.T) { + type args struct { + session idp.Session + encryptionAlg crypto.EncryptionAlgorithm + } + type res struct { + accessToken *crypto.CryptoValue + idToken string + err error + } + tests := []struct { + name string + args args + res res + }{ + { + "no tokens", + args{ + &ldap.Session{}, + crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res{ + accessToken: nil, + idToken: "", + err: nil, + }, + }, + { + "token encryption fails", + args{ + &oauth.Session{ + Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + }, + }, + }, + func() crypto.EncryptionAlgorithm { + m := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) + m.EXPECT().Encrypt(gomock.Any()).Return(nil, z_errors.ThrowInternal(nil, "id", "encryption failed")) + return m + }(), + }, + res{ + accessToken: nil, + idToken: "", + err: z_errors.ThrowInternal(nil, "id", "encryption failed"), + }, + }, + { + "oauth tokens", + args{ + &oauth.Session{ + Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + }, + }, + }, + crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res{ + accessToken: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("accessToken"), + }, + idToken: "", + err: nil, + }, + }, + { + "oidc tokens", + args{ + &openid.Session{ + Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + }, + IDToken: "idToken", + }, + }, + crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res{ + accessToken: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("accessToken"), + }, + idToken: "idToken", + err: nil, + }, + }, + { + "jwt tokens", + args{ + &jwt.Session{ + Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ + IDToken: "idToken", + }, + }, + crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + res{ + accessToken: nil, + idToken: "idToken", + err: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotAccessToken, gotIDToken, err := tokensForSucceededIDPIntent(tt.args.session, tt.args.encryptionAlg) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.accessToken, gotAccessToken) + assert.Equal(t, tt.res.idToken, gotIDToken) + }) + } +} diff --git a/internal/command/idp_model.go b/internal/command/idp_model.go index 2e098fecf..036a08aa9 100644 --- a/internal/command/idp_model.go +++ b/internal/command/idp_model.go @@ -1,14 +1,31 @@ package command import ( + "net/http" "reflect" "time" + "github.com/zitadel/logging" + "github.com/zitadel/oidc/v2/pkg/client/rp" + "golang.org/x/oauth2" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" + providers "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/azuread" + "github.com/zitadel/zitadel/internal/idp/providers/github" + "github.com/zitadel/zitadel/internal/idp/providers/gitlab" + "github.com/zitadel/zitadel/internal/idp/providers/google" + "github.com/zitadel/zitadel/internal/idp/providers/jwt" + "github.com/zitadel/zitadel/internal/idp/providers/ldap" + "github.com/zitadel/zitadel/internal/idp/providers/oauth" + "github.com/zitadel/zitadel/internal/idp/providers/oidc" "github.com/zitadel/zitadel/internal/repository/idp" "github.com/zitadel/zitadel/internal/repository/idpconfig" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" ) type OAuthIDPWriteModel struct { @@ -133,6 +150,45 @@ func (wm *OAuthIDPWriteModel) NewChanges( return changes, nil } +func (wm *OAuthIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { + secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg) + if err != nil { + return nil, err + } + config := &oauth2.Config{ + ClientID: wm.ClientID, + ClientSecret: secret, + Endpoint: oauth2.Endpoint{ + AuthURL: wm.AuthorizationEndpoint, + TokenURL: wm.TokenEndpoint, + }, + RedirectURL: callbackURL, + Scopes: wm.Scopes, + } + opts := make([]oauth.ProviderOpts, 0, 4) + if wm.IsCreationAllowed { + opts = append(opts, oauth.WithCreationAllowed()) + } + if wm.IsLinkingAllowed { + opts = append(opts, oauth.WithLinkingAllowed()) + } + if wm.IsAutoCreation { + opts = append(opts, oauth.WithAutoCreation()) + } + if wm.IsAutoUpdate { + opts = append(opts, oauth.WithAutoUpdate()) + } + return oauth.New( + config, + wm.Name, + wm.UserEndpoint, + func() providers.User { + return oauth.NewUserMapper(wm.IDAttribute) + }, + opts..., + ) +} + type OIDCIDPWriteModel struct { eventstore.WriteModel @@ -286,6 +342,40 @@ func (wm *OIDCIDPWriteModel) reduceOIDCConfigChangedEvent(e *idpconfig.OIDCConfi } } +func (wm *OIDCIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { + secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg) + if err != nil { + return nil, err + } + opts := make([]oidc.ProviderOpts, 1, 6) + opts[0] = oidc.WithSelectAccount() + if wm.IsIDTokenMapping { + opts = append(opts, oidc.WithIDTokenMapping()) + } + if wm.IsCreationAllowed { + opts = append(opts, oidc.WithCreationAllowed()) + } + if wm.IsLinkingAllowed { + opts = append(opts, oidc.WithLinkingAllowed()) + } + if wm.IsAutoCreation { + opts = append(opts, oidc.WithAutoCreation()) + } + if wm.IsAutoUpdate { + opts = append(opts, oidc.WithAutoUpdate()) + } + return oidc.New( + wm.Name, + wm.Issuer, + wm.ClientID, + secret, + callbackURL, + wm.Scopes, + oidc.DefaultMapper, + opts..., + ) +} + type JWTIDPWriteModel struct { eventstore.WriteModel @@ -423,6 +513,31 @@ func (wm *JWTIDPWriteModel) reduceJWTConfigChangedEvent(e *idpconfig.JWTConfigCh } } +func (wm *JWTIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { + opts := make([]jwt.ProviderOpts, 0) + if wm.IsCreationAllowed { + opts = append(opts, jwt.WithCreationAllowed()) + } + if wm.IsLinkingAllowed { + opts = append(opts, jwt.WithLinkingAllowed()) + } + if wm.IsAutoCreation { + opts = append(opts, jwt.WithAutoCreation()) + } + if wm.IsAutoUpdate { + opts = append(opts, jwt.WithAutoUpdate()) + } + return jwt.New( + wm.Name, + wm.Issuer, + wm.JWTEndpoint, + wm.KeysEndpoint, + wm.HeaderName, + idpAlg, + opts..., + ) +} + type AzureADIDPWriteModel struct { eventstore.WriteModel @@ -527,6 +642,43 @@ func (wm *AzureADIDPWriteModel) NewChanges( } return changes, nil } +func (wm *AzureADIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { + secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg) + if err != nil { + return nil, err + } + opts := make([]azuread.ProviderOptions, 0, 3) + if wm.IsEmailVerified { + opts = append(opts, azuread.WithEmailVerified()) + } + if wm.Tenant != "" { + opts = append(opts, azuread.WithTenant(azuread.TenantType(wm.Tenant))) + } + oauthOpts := make([]oauth.ProviderOpts, 0, 4) + if wm.IsCreationAllowed { + oauthOpts = append(oauthOpts, oauth.WithCreationAllowed()) + } + if wm.IsLinkingAllowed { + oauthOpts = append(oauthOpts, oauth.WithLinkingAllowed()) + } + if wm.IsAutoCreation { + oauthOpts = append(oauthOpts, oauth.WithAutoCreation()) + } + if wm.IsAutoUpdate { + oauthOpts = append(oauthOpts, oauth.WithAutoUpdate()) + } + if len(oauthOpts) > 0 { + opts = append(opts, azuread.WithOAuthOptions(oauthOpts...)) + } + return azuread.New( + wm.Name, + wm.ClientID, + secret, + callbackURL, + wm.Scopes, + opts..., + ) +} type GitHubIDPWriteModel struct { eventstore.WriteModel @@ -614,6 +766,32 @@ func (wm *GitHubIDPWriteModel) NewChanges( } return changes, nil } +func (wm *GitHubIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { + secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg) + if err != nil { + return nil, err + } + oauthOpts := make([]oauth.ProviderOpts, 0, 4) + if wm.IsCreationAllowed { + oauthOpts = append(oauthOpts, oauth.WithCreationAllowed()) + } + if wm.IsLinkingAllowed { + oauthOpts = append(oauthOpts, oauth.WithLinkingAllowed()) + } + if wm.IsAutoCreation { + oauthOpts = append(oauthOpts, oauth.WithAutoCreation()) + } + if wm.IsAutoUpdate { + oauthOpts = append(oauthOpts, oauth.WithAutoUpdate()) + } + return github.New( + wm.ClientID, + secret, + callbackURL, + wm.Scopes, + oauthOpts..., + ) +} type GitHubEnterpriseIDPWriteModel struct { eventstore.WriteModel @@ -728,6 +906,37 @@ func (wm *GitHubEnterpriseIDPWriteModel) NewChanges( return changes, nil } +func (wm *GitHubEnterpriseIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { + secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg) + if err != nil { + return nil, err + } + oauthOpts := make([]oauth.ProviderOpts, 0, 4) + if wm.IsCreationAllowed { + oauthOpts = append(oauthOpts, oauth.WithCreationAllowed()) + } + if wm.IsLinkingAllowed { + oauthOpts = append(oauthOpts, oauth.WithLinkingAllowed()) + } + if wm.IsAutoCreation { + oauthOpts = append(oauthOpts, oauth.WithAutoCreation()) + } + if wm.IsAutoUpdate { + oauthOpts = append(oauthOpts, oauth.WithAutoUpdate()) + } + return github.NewCustomURL( + wm.Name, + wm.ClientID, + secret, + callbackURL, + wm.AuthorizationEndpoint, + wm.TokenEndpoint, + wm.UserEndpoint, + wm.Scopes, + oauthOpts..., + ) +} + type GitLabIDPWriteModel struct { eventstore.WriteModel @@ -815,6 +1024,33 @@ func (wm *GitLabIDPWriteModel) NewChanges( return changes, nil } +func (wm *GitLabIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { + secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg) + if err != nil { + return nil, err + } + opts := make([]oidc.ProviderOpts, 0, 4) + if wm.IsCreationAllowed { + opts = append(opts, oidc.WithCreationAllowed()) + } + if wm.IsLinkingAllowed { + opts = append(opts, oidc.WithLinkingAllowed()) + } + if wm.IsAutoCreation { + opts = append(opts, oidc.WithAutoCreation()) + } + if wm.IsAutoUpdate { + opts = append(opts, oidc.WithAutoUpdate()) + } + return gitlab.New( + wm.ClientID, + secret, + callbackURL, + wm.Scopes, + opts..., + ) +} + type GitLabSelfHostedIDPWriteModel struct { eventstore.WriteModel @@ -910,6 +1146,35 @@ func (wm *GitLabSelfHostedIDPWriteModel) NewChanges( return changes, nil } +func (wm *GitLabSelfHostedIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { + secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg) + if err != nil { + return nil, err + } + opts := make([]oidc.ProviderOpts, 0, 4) + if wm.IsCreationAllowed { + opts = append(opts, oidc.WithCreationAllowed()) + } + if wm.IsLinkingAllowed { + opts = append(opts, oidc.WithLinkingAllowed()) + } + if wm.IsAutoCreation { + opts = append(opts, oidc.WithAutoCreation()) + } + if wm.IsAutoUpdate { + opts = append(opts, oidc.WithAutoUpdate()) + } + return gitlab.NewCustomIssuer( + wm.Name, + wm.Issuer, + wm.ClientID, + secret, + callbackURL, + wm.Scopes, + opts..., + ) +} + type GoogleIDPWriteModel struct { eventstore.WriteModel @@ -997,6 +1262,38 @@ func (wm *GoogleIDPWriteModel) NewChanges( return changes, nil } +func (wm *GoogleIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { + errorHandler := func(w http.ResponseWriter, r *http.Request, errorType string, errorDesc string, state string) { + logging.Errorf("token exchanged failed: %s - %s (state: %s)", errorType, errorType, state) + rp.DefaultErrorHandler(w, r, errorType, errorDesc, state) + } + oidc.WithRelyingPartyOption(rp.WithErrorHandler(errorHandler)) + secret, err := crypto.DecryptString(wm.ClientSecret, idpAlg) + if err != nil { + return nil, err + } + opts := make([]oidc.ProviderOpts, 0, 4) + if wm.IsCreationAllowed { + opts = append(opts, oidc.WithCreationAllowed()) + } + if wm.IsLinkingAllowed { + opts = append(opts, oidc.WithLinkingAllowed()) + } + if wm.IsAutoCreation { + opts = append(opts, oidc.WithAutoCreation()) + } + if wm.IsAutoUpdate { + opts = append(opts, oidc.WithAutoUpdate()) + } + return google.New( + wm.ClientID, + secret, + callbackURL, + wm.Scopes, + opts..., + ) +} + type LDAPIDPWriteModel struct { eventstore.WriteModel @@ -1157,6 +1454,81 @@ func (wm *LDAPIDPWriteModel) NewChanges( return changes, nil } +func (wm *LDAPIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { + password, err := crypto.DecryptString(wm.BindPassword, idpAlg) + if err != nil { + return nil, err + } + var opts []ldap.ProviderOpts + if !wm.StartTLS { + opts = append(opts, ldap.WithoutStartTLS()) + } + if wm.LDAPAttributes.IDAttribute != "" { + opts = append(opts, ldap.WithCustomIDAttribute(wm.LDAPAttributes.IDAttribute)) + } + if wm.LDAPAttributes.FirstNameAttribute != "" { + opts = append(opts, ldap.WithFirstNameAttribute(wm.LDAPAttributes.FirstNameAttribute)) + } + if wm.LDAPAttributes.LastNameAttribute != "" { + opts = append(opts, ldap.WithLastNameAttribute(wm.LDAPAttributes.LastNameAttribute)) + } + if wm.LDAPAttributes.DisplayNameAttribute != "" { + opts = append(opts, ldap.WithDisplayNameAttribute(wm.LDAPAttributes.DisplayNameAttribute)) + } + if wm.LDAPAttributes.NickNameAttribute != "" { + opts = append(opts, ldap.WithNickNameAttribute(wm.LDAPAttributes.NickNameAttribute)) + } + if wm.LDAPAttributes.PreferredUsernameAttribute != "" { + opts = append(opts, ldap.WithPreferredUsernameAttribute(wm.LDAPAttributes.PreferredUsernameAttribute)) + } + if wm.LDAPAttributes.EmailAttribute != "" { + opts = append(opts, ldap.WithEmailAttribute(wm.LDAPAttributes.EmailAttribute)) + } + if wm.LDAPAttributes.EmailVerifiedAttribute != "" { + opts = append(opts, ldap.WithEmailVerifiedAttribute(wm.LDAPAttributes.EmailVerifiedAttribute)) + } + if wm.LDAPAttributes.PhoneAttribute != "" { + opts = append(opts, ldap.WithPhoneAttribute(wm.LDAPAttributes.PhoneAttribute)) + } + if wm.LDAPAttributes.PhoneVerifiedAttribute != "" { + opts = append(opts, ldap.WithPhoneVerifiedAttribute(wm.LDAPAttributes.PhoneVerifiedAttribute)) + } + if wm.LDAPAttributes.PreferredLanguageAttribute != "" { + opts = append(opts, ldap.WithPreferredLanguageAttribute(wm.LDAPAttributes.PreferredLanguageAttribute)) + } + if wm.LDAPAttributes.AvatarURLAttribute != "" { + opts = append(opts, ldap.WithAvatarURLAttribute(wm.LDAPAttributes.AvatarURLAttribute)) + } + if wm.LDAPAttributes.ProfileAttribute != "" { + opts = append(opts, ldap.WithProfileAttribute(wm.LDAPAttributes.ProfileAttribute)) + } + if wm.IsCreationAllowed { + opts = append(opts, ldap.WithCreationAllowed()) + } + if wm.IsLinkingAllowed { + opts = append(opts, ldap.WithLinkingAllowed()) + } + if wm.IsAutoCreation { + opts = append(opts, ldap.WithAutoCreation()) + } + if wm.IsAutoUpdate { + opts = append(opts, ldap.WithAutoUpdate()) + } + return ldap.New( + wm.Name, + wm.Servers, + wm.BaseDN, + wm.BindDN, + password, + wm.UserBase, + wm.UserObjectClasses, + wm.UserFilters, + wm.Timeout, + callbackURL, + opts..., + ), nil +} + type IDPRemoveWriteModel struct { eventstore.WriteModel @@ -1211,3 +1583,252 @@ func (wm *IDPRemoveWriteModel) reduceRemoved(id string) { } wm.State = domain.IDPStateRemoved } + +type IDPTypeWriteModel struct { + eventstore.WriteModel + + ID string + Type domain.IDPType + State domain.IDPState +} + +func NewIDPTypeWriteModel(id string) *IDPTypeWriteModel { + return &IDPTypeWriteModel{ + ID: id, + } +} + +func (wm *IDPTypeWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *instance.OAuthIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeOAuth, e.Aggregate()) + case *org.OAuthIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeOAuth, e.Aggregate()) + case *instance.OIDCIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeOIDC, e.Aggregate()) + case *org.OIDCIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeOIDC, e.Aggregate()) + case *instance.JWTIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeJWT, e.Aggregate()) + case *org.JWTIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeJWT, e.Aggregate()) + case *instance.AzureADIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeAzureAD, e.Aggregate()) + case *org.AzureADIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeAzureAD, e.Aggregate()) + case *instance.GitHubIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeGitHub, e.Aggregate()) + case *org.GitHubIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeGitHub, e.Aggregate()) + case *instance.GitHubEnterpriseIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeGitHubEnterprise, e.Aggregate()) + case *org.GitHubEnterpriseIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeGitHubEnterprise, e.Aggregate()) + case *instance.GitLabIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeGitLab, e.Aggregate()) + case *org.GitLabIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeGitLab, e.Aggregate()) + case *instance.GitLabSelfHostedIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeGitLabSelfHosted, e.Aggregate()) + case *org.GitLabSelfHostedIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeGitLabSelfHosted, e.Aggregate()) + case *instance.GoogleIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeGoogle, e.Aggregate()) + case *org.GoogleIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeGoogle, e.Aggregate()) + case *instance.LDAPIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeLDAP, e.Aggregate()) + case *org.LDAPIDPAddedEvent: + wm.reduceAdded(e.ID, domain.IDPTypeLDAP, e.Aggregate()) + case *instance.IDPRemovedEvent: + wm.reduceRemoved(e.ID) + case *org.IDPRemovedEvent: + wm.reduceRemoved(e.ID) + case *instance.IDPConfigAddedEvent: + if e.Typ == domain.IDPConfigTypeOIDC { + wm.reduceAdded(e.ConfigID, domain.IDPTypeOIDC, e.Aggregate()) + } else if e.Typ == domain.IDPConfigTypeJWT { + wm.reduceAdded(e.ConfigID, domain.IDPTypeJWT, e.Aggregate()) + } + case *org.IDPConfigAddedEvent: + if e.Typ == domain.IDPConfigTypeOIDC { + wm.reduceAdded(e.ConfigID, domain.IDPTypeOIDC, e.Aggregate()) + } else if e.Typ == domain.IDPConfigTypeJWT { + wm.reduceAdded(e.ConfigID, domain.IDPTypeJWT, e.Aggregate()) + } + case *instance.IDPConfigRemovedEvent: + wm.reduceRemoved(e.ConfigID) + case *org.IDPConfigRemovedEvent: + wm.reduceRemoved(e.ConfigID) + } + } + return wm.WriteModel.Reduce() +} + +func (wm *IDPTypeWriteModel) reduceAdded(id string, t domain.IDPType, agg eventstore.Aggregate) { + if wm.ID != id { + return + } + wm.Type = t + wm.State = domain.IDPStateActive + wm.ResourceOwner = agg.ResourceOwner + wm.InstanceID = agg.InstanceID +} + +func (wm *IDPTypeWriteModel) reduceRemoved(id string) { + if wm.ID != id { + return + } + wm.Type = domain.IDPTypeUnspecified + wm.State = domain.IDPStateRemoved + wm.ResourceOwner = "" + wm.InstanceID = "" +} + +func (wm *IDPTypeWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(instance.AggregateType). + EventTypes( + instance.OAuthIDPAddedEventType, + instance.OIDCIDPAddedEventType, + instance.JWTIDPAddedEventType, + instance.AzureADIDPAddedEventType, + instance.GitHubIDPAddedEventType, + instance.GitHubEnterpriseIDPAddedEventType, + instance.GitLabIDPAddedEventType, + instance.GitLabSelfHostedIDPAddedEventType, + instance.GoogleIDPAddedEventType, + instance.LDAPIDPAddedEventType, + instance.IDPRemovedEventType, + ). + EventData(map[string]interface{}{"id": wm.ID}). + Or(). + AggregateTypes(org.AggregateType). + EventTypes( + org.OAuthIDPAddedEventType, + org.OIDCIDPAddedEventType, + org.JWTIDPAddedEventType, + org.AzureADIDPAddedEventType, + org.GitHubIDPAddedEventType, + org.GitHubEnterpriseIDPAddedEventType, + org.GitLabIDPAddedEventType, + org.GitLabSelfHostedIDPAddedEventType, + org.GoogleIDPAddedEventType, + org.LDAPIDPAddedEventType, + org.IDPRemovedEventType, + ). + EventData(map[string]interface{}{"id": wm.ID}). + Or(). // old events + AggregateTypes(instance.AggregateType). + EventTypes( + instance.IDPConfigAddedEventType, + instance.IDPConfigRemovedEventType, + ). + EventData(map[string]interface{}{"idpConfigId": wm.ID}). + Or(). + AggregateTypes(org.AggregateType). + EventTypes( + org.IDPConfigAddedEventType, + org.IDPConfigRemovedEventType, + ). + EventData(map[string]interface{}{"idpConfigId": wm.ID}). + Builder() +} + +type IDP interface { + eventstore.QueryReducer + ToProvider(string, crypto.EncryptionAlgorithm) (providers.Provider, error) +} + +type AllIDPWriteModel struct { + model IDP + + ID string + IDPType domain.IDPType + ResourceOwner string + Instance bool +} + +func NewAllIDPWriteModel(resourceOwner string, instanceBool bool, id string, idpType domain.IDPType) (*AllIDPWriteModel, error) { + writeModel := &AllIDPWriteModel{ + ID: id, + IDPType: idpType, + ResourceOwner: resourceOwner, + Instance: instanceBool, + } + + if instanceBool { + switch idpType { + case domain.IDPTypeOIDC: + writeModel.model = NewOIDCInstanceIDPWriteModel(resourceOwner, id) + case domain.IDPTypeJWT: + writeModel.model = NewJWTInstanceIDPWriteModel(resourceOwner, id) + case domain.IDPTypeOAuth: + writeModel.model = NewOAuthInstanceIDPWriteModel(resourceOwner, id) + case domain.IDPTypeLDAP: + writeModel.model = NewLDAPInstanceIDPWriteModel(resourceOwner, id) + case domain.IDPTypeAzureAD: + writeModel.model = NewAzureADInstanceIDPWriteModel(resourceOwner, id) + case domain.IDPTypeGitHub: + writeModel.model = NewGitHubInstanceIDPWriteModel(resourceOwner, id) + case domain.IDPTypeGitHubEnterprise: + writeModel.model = NewGitHubEnterpriseInstanceIDPWriteModel(resourceOwner, id) + case domain.IDPTypeGitLab: + writeModel.model = NewGitLabInstanceIDPWriteModel(resourceOwner, id) + case domain.IDPTypeGitLabSelfHosted: + writeModel.model = NewGitLabSelfHostedInstanceIDPWriteModel(resourceOwner, id) + case domain.IDPTypeGoogle: + writeModel.model = NewGoogleInstanceIDPWriteModel(resourceOwner, id) + case domain.IDPTypeUnspecified: + fallthrough + default: + return nil, errors.ThrowInternal(nil, "COMMAND-xw921211", "Errors.IDPConfig.NotExisting") + } + } else { + switch idpType { + case domain.IDPTypeOIDC: + writeModel.model = NewOIDCOrgIDPWriteModel(resourceOwner, id) + case domain.IDPTypeJWT: + writeModel.model = NewJWTOrgIDPWriteModel(resourceOwner, id) + case domain.IDPTypeOAuth: + writeModel.model = NewOAuthOrgIDPWriteModel(resourceOwner, id) + case domain.IDPTypeLDAP: + writeModel.model = NewLDAPOrgIDPWriteModel(resourceOwner, id) + case domain.IDPTypeAzureAD: + writeModel.model = NewAzureADOrgIDPWriteModel(resourceOwner, id) + case domain.IDPTypeGitHub: + writeModel.model = NewGitHubOrgIDPWriteModel(resourceOwner, id) + case domain.IDPTypeGitHubEnterprise: + writeModel.model = NewGitHubEnterpriseOrgIDPWriteModel(resourceOwner, id) + case domain.IDPTypeGitLab: + writeModel.model = NewGitLabOrgIDPWriteModel(resourceOwner, id) + case domain.IDPTypeGitLabSelfHosted: + writeModel.model = NewGitLabSelfHostedOrgIDPWriteModel(resourceOwner, id) + case domain.IDPTypeGoogle: + writeModel.model = NewGoogleOrgIDPWriteModel(resourceOwner, id) + case domain.IDPTypeUnspecified: + fallthrough + default: + return nil, errors.ThrowInternal(nil, "COMMAND-xw921111", "Errors.IDPConfig.NotExisting") + } + } + return writeModel, nil +} + +func (wm *AllIDPWriteModel) Reduce() error { + return wm.model.Reduce() +} + +func (wm *AllIDPWriteModel) Query() *eventstore.SearchQueryBuilder { + return wm.model.Query() +} + +func (wm *AllIDPWriteModel) AppendEvents(events ...eventstore.Event) { + wm.model.AppendEvents(events...) +} + +func (wm *AllIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) { + return wm.model.ToProvider(callbackURL, idpAlg) +} diff --git a/internal/command/idp_model_test.go b/internal/command/idp_model_test.go new file mode 100644 index 000000000..86626d4b4 --- /dev/null +++ b/internal/command/idp_model_test.go @@ -0,0 +1,323 @@ +package command + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/errors" +) + +func TestCommands_AllIDPWriteModel(t *testing.T) { + type args struct { + resourceOwner string + instanceBool bool + id string + idpType domain.IDPType + } + type res struct { + writeModelType interface{} + err error + } + tests := []struct { + name string + args args + res res + }{ + { + name: "writemodel instance oidc", + args: args{ + resourceOwner: "owner", + instanceBool: true, + id: "id", + idpType: domain.IDPTypeOIDC, + }, + res: res{ + writeModelType: &InstanceOIDCIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel instance jwt", + args: args{ + resourceOwner: "owner", + instanceBool: true, + id: "id", + idpType: domain.IDPTypeJWT, + }, + res: res{ + writeModelType: &InstanceJWTIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel instance oauth", + args: args{ + resourceOwner: "owner", + instanceBool: true, + id: "id", + idpType: domain.IDPTypeOAuth, + }, + res: res{ + writeModelType: &InstanceOAuthIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel instance ldap", + args: args{ + resourceOwner: "owner", + instanceBool: true, + id: "id", + idpType: domain.IDPTypeLDAP, + }, + res: res{ + writeModelType: &InstanceLDAPIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel instance azureAD", + args: args{ + resourceOwner: "owner", + instanceBool: true, + id: "id", + idpType: domain.IDPTypeAzureAD, + }, + res: res{ + writeModelType: &InstanceAzureADIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel instance github", + args: args{ + resourceOwner: "owner", + instanceBool: true, + id: "id", + idpType: domain.IDPTypeGitHub, + }, + res: res{ + writeModelType: &InstanceGitHubIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel instance github enterprise", + args: args{ + resourceOwner: "owner", + instanceBool: true, + id: "id", + idpType: domain.IDPTypeGitHubEnterprise, + }, + res: res{ + writeModelType: &InstanceGitHubEnterpriseIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel instance gitlab", + args: args{ + resourceOwner: "owner", + instanceBool: true, + id: "id", + idpType: domain.IDPTypeGitLab, + }, + res: res{ + writeModelType: &InstanceGitLabIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel instance gitlab self hosted", + args: args{ + resourceOwner: "owner", + instanceBool: true, + id: "id", + idpType: domain.IDPTypeGitLabSelfHosted, + }, + res: res{ + writeModelType: &InstanceGitLabSelfHostedIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel instance google", + args: args{ + resourceOwner: "owner", + instanceBool: true, + id: "id", + idpType: domain.IDPTypeGoogle, + }, + res: res{ + writeModelType: &InstanceGoogleIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel instance unspecified", + args: args{ + resourceOwner: "owner", + instanceBool: true, + id: "id", + idpType: domain.IDPTypeUnspecified, + }, + res: res{ + err: errors.ThrowInternal(nil, "COMMAND-xw921211", "Errors.IDPConfig.NotExisting"), + }, + }, + { + name: "writemodel org oidc", + args: args{ + resourceOwner: "owner", + instanceBool: false, + id: "id", + idpType: domain.IDPTypeOIDC, + }, + res: res{ + writeModelType: &OrgOIDCIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel org jwt", + args: args{ + resourceOwner: "owner", + instanceBool: false, + id: "id", + idpType: domain.IDPTypeJWT, + }, + res: res{ + writeModelType: &OrgJWTIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel org oauth", + args: args{ + resourceOwner: "owner", + instanceBool: false, + id: "id", + idpType: domain.IDPTypeOAuth, + }, + res: res{ + writeModelType: &OrgOAuthIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel org ldap", + args: args{ + resourceOwner: "owner", + instanceBool: false, + id: "id", + idpType: domain.IDPTypeLDAP, + }, + res: res{ + writeModelType: &OrgLDAPIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel org azureAD", + args: args{ + resourceOwner: "owner", + instanceBool: false, + id: "id", + idpType: domain.IDPTypeAzureAD, + }, + res: res{ + writeModelType: &OrgAzureADIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel org github", + args: args{ + resourceOwner: "owner", + instanceBool: false, + id: "id", + idpType: domain.IDPTypeGitHub, + }, + res: res{ + writeModelType: &OrgGitHubIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel org github enterprise", + args: args{ + resourceOwner: "owner", + instanceBool: false, + id: "id", + idpType: domain.IDPTypeGitHubEnterprise, + }, + res: res{ + writeModelType: &OrgGitHubEnterpriseIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel org gitlab", + args: args{ + resourceOwner: "owner", + instanceBool: false, + id: "id", + idpType: domain.IDPTypeGitLab, + }, + res: res{ + writeModelType: &OrgGitLabIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel org gitlab self hosted", + args: args{ + resourceOwner: "owner", + instanceBool: false, + id: "id", + idpType: domain.IDPTypeGitLabSelfHosted, + }, + res: res{ + writeModelType: &OrgGitLabSelfHostedIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel org google", + args: args{ + resourceOwner: "owner", + instanceBool: false, + id: "id", + idpType: domain.IDPTypeGoogle, + }, + res: res{ + writeModelType: &OrgGoogleIDPWriteModel{}, + err: nil, + }, + }, + { + name: "writemodel org unspecified", + args: args{ + resourceOwner: "owner", + instanceBool: false, + id: "id", + idpType: domain.IDPTypeUnspecified, + }, + res: res{ + err: errors.ThrowInternal(nil, "COMMAND-xw921111", "Errors.IDPConfig.NotExisting"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wm, err := NewAllIDPWriteModel(tt.args.resourceOwner, tt.args.instanceBool, tt.args.id, tt.args.idpType) + require.ErrorIs(t, err, tt.res.err) + if wm != nil { + assert.IsType(t, tt.res.writeModelType, wm.model) + } + }) + } +} diff --git a/internal/command/main_test.go b/internal/command/main_test.go index 10fc1c2f6..5c7b42d3a 100644 --- a/internal/command/main_test.go +++ b/internal/command/main_test.go @@ -16,6 +16,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore/repository" "github.com/zitadel/zitadel/internal/eventstore/repository/mock" action_repo "github.com/zitadel/zitadel/internal/repository/action" + "github.com/zitadel/zitadel/internal/repository/idpintent" iam_repo "github.com/zitadel/zitadel/internal/repository/instance" key_repo "github.com/zitadel/zitadel/internal/repository/keypair" "github.com/zitadel/zitadel/internal/repository/org" @@ -41,6 +42,7 @@ func eventstoreExpect(t *testing.T, expects ...expect) *eventstore.Eventstore { key_repo.RegisterEventMappers(es) action_repo.RegisterEventMappers(es) session.RegisterEventMappers(es) + idpintent.RegisterEventMappers(es) return es } diff --git a/internal/command/user_human.go b/internal/command/user_human.go index 0bb075a34..061dc2232 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -58,6 +58,9 @@ type AddHuman struct { Register bool Metadata []*AddMetadataEntry + // Links are optional + Links []*AddLink + // Details are set after a successful execution of the command Details *domain.ObjectDetails @@ -65,6 +68,12 @@ type AddHuman struct { EmailCode *string } +type AddLink struct { + IDPID string + DisplayName string + IDPExternalID string +} + func (h *AddHuman) Validate() (err error) { if err := h.Email.Validate(); err != nil { return err @@ -226,6 +235,13 @@ func (c *Commands) AddHumanCommand(human *AddHuman, orgID string, passwordAlg cr metadataEntry.Value, )) } + for _, link := range human.Links { + cmd, err := addLink(ctx, filter, a, link) + if err != nil { + return nil, err + } + cmds = append(cmds, cmd) + } return cmds, nil }, nil @@ -260,6 +276,15 @@ func (c *Commands) addHumanCommandEmail(ctx context.Context, filter preparation. } return cmds, nil } + +func addLink(ctx context.Context, filter preparation.FilterToQueryReducer, a *user.Aggregate, link *AddLink) (eventstore.Command, error) { + exists, err := ExistsIDP(ctx, filter, link.IDPID, a.ResourceOwner) + if !exists || err != nil { + return nil, errors.ThrowPreconditionFailed(err, "COMMAND-39nf2", "Errors.IDPConfig.NotExisting") + } + return user.NewUserIDPLinkAddedEvent(ctx, &a.Aggregate, link.IDPID, link.DisplayName, link.IDPExternalID), nil +} + func (c *Commands) addHumanCommandPhone(ctx context.Context, filter preparation.FilterToQueryReducer, cmds []eventstore.Command, a *user.Aggregate, human *AddHuman, codeAlg crypto.EncryptionAlgorithm) ([]eventstore.Command, error) { if human.Phone.Number == "" { return cmds, nil diff --git a/internal/command/user_idp_link.go b/internal/command/user_idp_link.go index 9db06bf6a..4c03f4643 100644 --- a/internal/command/user_idp_link.go +++ b/internal/command/user_idp_link.go @@ -3,20 +3,25 @@ package command import ( "context" - "github.com/zitadel/zitadel/internal/eventstore" - + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" caos_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/telemetry/tracing" ) -func (c *Commands) AddUserIDPLink(ctx context.Context, userID, resourceOwner string, link *domain.UserIDPLink) (err error) { +func (c *Commands) AddUserIDPLink(ctx context.Context, userID, resourceOwner string, link *domain.UserIDPLink) (_ *domain.ObjectDetails, err error) { if userID == "" { - return caos_errs.ThrowInvalidArgument(nil, "COMMAND-03j8f", "Errors.IDMissing") + return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-03j8f", "Errors.IDMissing") } if err := c.checkUserExists(ctx, userID, resourceOwner); err != nil { - return err + return nil, err + } + if userID != authz.GetCtxData(ctx).UserID { + if err := c.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, userID); err != nil { + return nil, err + } } linkWriteModel := NewUserIDPLinkWriteModel(userID, link.IDPConfigID, link.ExternalUserID, resourceOwner) @@ -24,11 +29,18 @@ func (c *Commands) AddUserIDPLink(ctx context.Context, userID, resourceOwner str event, err := c.addUserIDPLink(ctx, userAgg, link) if err != nil { - return err + return nil, err } - _, err = c.eventstore.Push(ctx, event) - return err + events, err := c.eventstore.Push(ctx, event) + if err != nil { + return nil, err + } + return &domain.ObjectDetails{ + Sequence: events[len(events)-1].Sequence(), + EventDate: events[len(events)-1].CreationDate(), + ResourceOwner: events[len(events)-1].Aggregate().ResourceOwner, + }, nil } func (c *Commands) BulkAddedUserIDPLinks(ctx context.Context, userID, resourceOwner string, links []*domain.UserIDPLink) (err error) { diff --git a/internal/domain/idp.go b/internal/domain/idp.go index fc0e8b91f..f276f8eb4 100644 --- a/internal/domain/idp.go +++ b/internal/domain/idp.go @@ -91,3 +91,22 @@ func (t IDPType) DisplayName() string { return "" } } + +type IDPIntentState int32 + +const ( + IDPIntentStateUnspecified IDPIntentState = iota + IDPIntentStateStarted + IDPIntentStateSucceeded + IDPIntentStateFailed + + idpIntentStateCount +) + +func (s IDPIntentState) Valid() bool { + return s >= 0 && s < idpIntentStateCount +} + +func (s IDPIntentState) Exists() bool { + return s != IDPIntentStateUnspecified && s != IDPIntentStateFailed //TODO: ? +} diff --git a/internal/idp/providers/github/github.go b/internal/idp/providers/github/github.go index bba9a6d9c..64e02941b 100644 --- a/internal/idp/providers/github/github.go +++ b/internal/idp/providers/github/github.go @@ -4,11 +4,10 @@ import ( "strconv" "time" - "github.com/zitadel/zitadel/internal/domain" - "golang.org/x/oauth2" "golang.org/x/text/language" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/idp" "github.com/zitadel/zitadel/internal/idp/providers/oauth" ) diff --git a/internal/query/query.go b/internal/query/query.go index 7e3f10dcd..17990311b 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -18,6 +18,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/repository/action" + "github.com/zitadel/zitadel/internal/repository/idpintent" iam_repo "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/keypair" "github.com/zitadel/zitadel/internal/repository/org" @@ -84,6 +85,7 @@ func StartQueries( keypair.RegisterEventMappers(repo.eventstore) usergrant.RegisterEventMappers(repo.eventstore) session.RegisterEventMappers(repo.eventstore) + idpintent.RegisterEventMappers(repo.eventstore) repo.idpConfigEncryption = idpConfigEncryption repo.multifactors = domain.MultifactorConfigs{ diff --git a/internal/repository/idpintent/aggregate.go b/internal/repository/idpintent/aggregate.go new file mode 100644 index 000000000..135100b21 --- /dev/null +++ b/internal/repository/idpintent/aggregate.go @@ -0,0 +1,29 @@ +package idpintent + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + instanceEventTypePrefix = eventstore.EventType("idpintent.") +) + +const ( + AggregateType = "idpintent" + AggregateVersion = "v1" +) + +type Aggregate struct { + eventstore.Aggregate +} + +func NewAggregate(id, resourceOwner string) *Aggregate { + return &Aggregate{ + Aggregate: eventstore.Aggregate{ + Type: AggregateType, + Version: AggregateVersion, + ID: id, + ResourceOwner: resourceOwner, + }, + } +} diff --git a/internal/repository/idpintent/eventstore.go b/internal/repository/idpintent/eventstore.go new file mode 100644 index 000000000..9e9b4fa15 --- /dev/null +++ b/internal/repository/idpintent/eventstore.go @@ -0,0 +1,11 @@ +package idpintent + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +func RegisterEventMappers(es *eventstore.Eventstore) { + es.RegisterFilterEventMapper(AggregateType, StartedEventType, StartedEventMapper). + RegisterFilterEventMapper(AggregateType, SucceededEventType, SucceededEventMapper). + RegisterFilterEventMapper(AggregateType, FailedEventType, FailedEventMapper) +} diff --git a/internal/repository/idpintent/intent.go b/internal/repository/idpintent/intent.go new file mode 100644 index 000000000..eea0bf68f --- /dev/null +++ b/internal/repository/idpintent/intent.go @@ -0,0 +1,159 @@ +package idpintent + +import ( + "context" + "encoding/json" + "net/url" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository" +) + +const ( + StartedEventType = instanceEventTypePrefix + "started" + SucceededEventType = instanceEventTypePrefix + "succeeded" + FailedEventType = instanceEventTypePrefix + "failed" +) + +type StartedEvent struct { + eventstore.BaseEvent `json:"-"` + + SuccessURL *url.URL `json:"successURL"` + FailureURL *url.URL `json:"failureURL"` + IDPID string `json:"idpId"` +} + +func NewStartedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + successURL, + failureURL *url.URL, + idpID string, +) *StartedEvent { + return &StartedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + StartedEventType, + ), + SuccessURL: successURL, + FailureURL: failureURL, + IDPID: idpID, + } +} + +func (e *StartedEvent) Data() interface{} { + return e +} + +func (e *StartedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func StartedEventMapper(event *repository.Event) (eventstore.Event, error) { + e := &StartedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + + err := json.Unmarshal(event.Data, e) + if err != nil { + return nil, errors.ThrowInternal(err, "IDP-Sf3f1", "unable to unmarshal event") + } + + return e, nil +} + +type SucceededEvent struct { + eventstore.BaseEvent `json:"-"` + + IDPUser []byte `json:"idpUser"` + UserID string `json:"userId,omitempty"` + IDPAccessToken *crypto.CryptoValue `json:"idpAccessToken,omitempty"` + IDPIDToken string `json:"idpIdToken,omitempty"` +} + +func NewSucceededEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + idpUser []byte, + userID string, + idpAccessToken *crypto.CryptoValue, + idpIDToken string, +) (*SucceededEvent, error) { + return &SucceededEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + SucceededEventType, + ), + IDPUser: idpUser, + UserID: userID, + IDPAccessToken: idpAccessToken, + IDPIDToken: idpIDToken, + }, nil +} + +func (e *SucceededEvent) Data() interface{} { + return e +} + +func (e *SucceededEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func SucceededEventMapper(event *repository.Event) (eventstore.Event, error) { + e := &SucceededEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + + err := json.Unmarshal(event.Data, e) + if err != nil { + return nil, errors.ThrowInternal(err, "IDP-HBreq", "unable to unmarshal event") + } + + return e, nil +} + +type FailedEvent struct { + eventstore.BaseEvent `json:"-"` + + Reason string `json:"reason,omitempty"` +} + +func NewFailedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + reason string, +) *FailedEvent { + return &FailedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + FailedEventType, + ), + Reason: reason, + } +} + +func (e *FailedEvent) Data() interface{} { + return e +} + +func (e *FailedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func FailedEventMapper(event *repository.Event) (eventstore.Event, error) { + e := &FailedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + + err := json.Unmarshal(event.Data, e) + if err != nil { + return nil, errors.ThrowInternal(err, "IDP-Sfer3", "unable to unmarshal event") + } + + return e, nil +} diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index a06fc614f..441b6be36 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -476,6 +476,15 @@ Errors: Terminated: Session bereits beendet Token: Invalid: Session Token ist ungültig + Intent: + IDPMissing: IDP ID fehlt im Request + SuccessURLMissing: Success URL fehlt im Request + FailureURLMissing: Failure URL fehlt im Request + StateMissing: State parameter fehlt im Request + NotStarted: Intent wurde nicht gestartet oder wurde bereits beendet + NotSucceeded: Intent war nicht erfolgreich + TokenCreationFailed: Tokenerstellung schlug fehl + InvalidToken: Intent Token ist ungültig AggregateTypes: action: Action diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index a1c2c6864..276ad07da 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -476,6 +476,15 @@ Errors: Terminated: Session already terminated Token: Invalid: Session Token is invalid + Intent: + IDPMissing: IDP ID is missing in the request + SuccessURLMissing: Success URL is missing in the request + FailureURLMissing: Failure URL is missing in the request + StateMissing: State parameter is missing in the request + NotStarted: Intent is not started or was already terminated + NotSucceeded: Intent has not succeeded + TokenCreationFailed: Token creation failed + InvalidToken: Intent Token is invalid AggregateTypes: action: Action diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 5e4f6bd01..757a73054 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -476,6 +476,15 @@ Errors: Terminated: Sesión ya terminada Token: Invalid: El identificador de sesión no es válido + Intent: + IDPMissing: Falta IDP en la solicitud + SuccessURLMissing: Falta la URL de éxito en la solicitud + FailureURLMissing: Falta la URL de error en la solicitud + StateMissing: Falta un parámetro de estado en la solicitud + NotStarted: La intención no se ha iniciado o ya ha finalizado + NotSucceeded: Intento fallido + TokenCreationFailed: Fallo en la creación del token + InvalidToken: El token de la intención no es válido AggregateTypes: action: Acción diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 21034f79b..eb5b5471e 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -476,6 +476,15 @@ Errors: Terminated: La session est déjà terminée Token: Invalid: Le jeton de session n'est pas valide + Intent: + IDPMissing: IDP manquant dans la requête + SuccessURLMissing: Success URL absent de la requête + FailureURLMissing: Failure URL absent de la requête + StateMissing: Paramètre d'état manquant dans la requête + NotStarted: Intent n'a pas démarré ou s'est déjà terminé + NotSucceeded: l'intention n'a pas abouti + TokenCreationFailed: La création du token a échoué + InvalidToken: Le jeton d'intention n'est pas valide AggregateTypes: action: Action diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 3a7f8d6b0..7ce4200b7 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -476,6 +476,15 @@ Errors: Terminated: Sessione già terminata Token: Invalid: Il token della sessione non è valido + Intent: + IDPMissing: IDP mancante nella richiesta + SuccessURLMissing: URL di successo mancante nella richiesta + FailureURLMissing: URL di errore mancante nella richiesta + StateMissing: parametro di stato mancante nella richiesta + NotStarted: l'intento non è stato avviato o è già stato terminato + NotSucceeded: l'intento non è andato a buon fine + TokenCreationFailed: creazione del token fallita + InvalidToken: Il token dell'intento non è valido AggregateTypes: action: Azione diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 99111a117..394880e11 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -465,6 +465,15 @@ Errors: Terminated: セッションはすでに終了しています Token: Invalid: セッショントークンが無効です + Intent: + IDPMissing: リクエストにIDP IDが含まれていません + SuccessURLMissing: リクエストに成功時の URL がありません + FailureURLMissing: リクエストに失敗の URL がありません + StateMissing: リクエストに State パラメータがありません + NotStarted: インテントが開始されなかったか、既に終了している + NotSucceeded: インテントが成功しなかった + TokenCreationFailed: トークンの作成に失敗しました + InvalidToken: インテントのトークンが無効である AggregateTypes: action: アクション diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 31103ff9a..3be75368b 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -476,6 +476,15 @@ Errors: Terminated: Sesja już zakończona Token: Invalid: Token sesji jest nieprawidłowy + Intent: + IDPMissing: Brak identyfikatora IDP w żądaniu + SuccessURLMissing: Brak adresu URL powodzenia w żądaniu + FailureURLMissing: Brak adresu URL niepowodzenia w żądaniu + StateMissing: Brak parametru stanu w żądaniu + NotStarted: Intencja nie została rozpoczęta lub już się zakończyła + NotSucceeded: intencja nie powiodła się + TokenCreationFailed: Tworzenie tokena nie powiodło się + InvalidToken: Token intencji jest nieprawidłowy AggregateTypes: action: Działanie diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 8fd96f97a..eb3a76c77 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -476,6 +476,15 @@ Errors: Terminated: 会话已经终止 Token: Invalid: 会话令牌是无效的 + Intent: + IDPMissing: 请求中缺少IDP ID + SuccessURLMissing: 请求中缺少成功URL + FailureURLMissing: 请求中缺少失败的URL + StateMissing: 请求中缺少状态参数 + NotStarted: 意图没有开始或已经结束 + NotSucceeded: 意图不成功 + TokenCreationFailed: 令牌创建失败 + InvalidToken: 意图令牌是无效的 AggregateTypes: action: 动作 diff --git a/proto/zitadel/user/v2alpha/idp.proto b/proto/zitadel/user/v2alpha/idp.proto new file mode 100644 index 000000000..ed8f389c3 --- /dev/null +++ b/proto/zitadel/user/v2alpha/idp.proto @@ -0,0 +1,51 @@ +syntax = "proto3"; + +package zitadel.user.v2alpha; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha;user"; + +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +message IDPInformation{ + oneof access{ + IDPOAuthAccessInformation oauth = 1; + } + bytes idp_information = 2; +} + +message IDPOAuthAccessInformation{ + string access_token = 1; + optional string id_token = 2; +} + +message IDPLink { + string idp_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "ID of the identity provider" + min_length: 1; + max_length: 200; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + string idp_external_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "ID of user of the identity provider" + min_length: 1; + max_length: 200; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + string display_name = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Display name of user of the identity provider" + min_length: 1; + max_length: 200; + example: "\"Firstname Lastname\""; + } + ]; +} diff --git a/proto/zitadel/user/v2alpha/user_service.proto b/proto/zitadel/user/v2alpha/user_service.proto index 66e1bc451..1af446f09 100644 --- a/proto/zitadel/user/v2alpha/user_service.proto +++ b/proto/zitadel/user/v2alpha/user_service.proto @@ -6,6 +6,7 @@ import "zitadel/object/v2alpha/object.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; import "zitadel/user/v2alpha/auth.proto"; import "zitadel/user/v2alpha/email.proto"; +import "zitadel/user/v2alpha/idp.proto"; import "zitadel/user/v2alpha/password.proto"; import "zitadel/user/v2alpha/user.proto"; import "google/api/annotations.proto"; @@ -158,7 +159,7 @@ service UserService { rpc RegisterPasskey (RegisterPasskeyRequest) returns (RegisterPasskeyResponse) { option (google.api.http) = { - post: "/users/{user_id}/passkeys" + post: "/v2alpha/users/{user_id}/passkeys" body: "*" }; @@ -180,7 +181,7 @@ service UserService { } rpc VerifyPasskeyRegistration (VerifyPasskeyRegistrationRequest) returns (VerifyPasskeyRegistrationResponse) { option (google.api.http) = { - post: "/users/{user_id}/passkeys/{passkey_id}" + post: "/v2alpha/users/{user_id}/passkeys/{passkey_id}" body: "*" }; @@ -202,7 +203,7 @@ service UserService { } rpc CreatePasskeyRegistrationLink (CreatePasskeyRegistrationLinkRequest) returns (CreatePasskeyRegistrationLinkResponse) { option (google.api.http) = { - post: "/users/{user_id}/passkeys/registration_link" + post: "/v2alpha/users/{user_id}/passkeys/registration_link" body: "*" }; @@ -222,6 +223,80 @@ service UserService { }; }; } + + // Start an IDP authentication (for external login, registration or linking) + rpc StartIdentityProviderFlow (StartIdentityProviderFlowRequest) returns (StartIdentityProviderFlowResponse) { + option (google.api.http) = { + post: "/v2alpha/users/idps/{idp_id}/start" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Start flow with an identity provider"; + description: "Start a flow with an identity provider, for external login, registration or linking"; + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc RetrieveIdentityProviderInformation (RetrieveIdentityProviderInformationRequest) returns (RetrieveIdentityProviderInformationResponse) { + option (google.api.http) = { + post: "/v2alpha/users/intents/{intent_id}/information" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Retrieve the information returned by the identity provider"; + description: "Retrieve the information returned by the identity provider for registration or updating an existing user with new information"; + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Link an IDP to an existing user + rpc AddIDPLink (AddIDPLinkRequest) returns (AddIDPLinkResponse) { + option (google.api.http) = { + post: "/v2alpha/users/users/{user_id}/links" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Add link to an identity provider to an user"; + description: "Add link to an identity provider to an user"; + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } } message AddHumanUserRequest{ @@ -257,6 +332,7 @@ message AddHumanUserRequest{ Password password = 7; HashedPassword hashed_password = 8; } + repeated IDPLink idp_links = 9; } message AddHumanUserResponse { @@ -430,3 +506,88 @@ message CreatePasskeyRegistrationLinkResponse{ } ]; } + +message StartIdentityProviderFlowRequest{ + string idp_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "ID for existing identity provider" + min_length: 1; + max_length: 200; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + string success_url = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200, uri_ref: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "URL on which the user will be redirected after a successful login" + min_length: 1; + max_length: 200; + example: "\"https://custom.com/login/idp/success\""; + } + ]; + string failure_url = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200, uri_ref: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "URL on which the user will be redirected after a failed login" + min_length: 1; + max_length: 200; + example: "\"https://custom.com/login/idp/fail\""; + } + ]; +} + +message StartIdentityProviderFlowResponse{ + zitadel.object.v2alpha.Details details = 1; + oneof next_step { + 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\""; + } + ]; + } +} + +message RetrieveIdentityProviderInformationRequest{ + string intent_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "ID of the intent, previously returned on the success response of the IDP callback" + min_length: 1; + max_length: 200; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + string token = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "token of the intent, previously returned on the success response of the IDP callback" + min_length: 1; + max_length: 200; + example: "\"SJKL3ioIDpo342ioqw98fjp3sdf32wahb=\""; + } + ]; +} + +message RetrieveIdentityProviderInformationResponse{ + zitadel.object.v2alpha.Details details = 1; + IDPInformation idp_information = 2; +} + +message AddIDPLinkRequest{ + string user_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: "\"69629026806489455\""; + } + ]; + IDPLink idp_link = 2; +} + +message AddIDPLinkResponse { + zitadel.object.v2alpha.Details details = 1; +}