diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index b63c84eb0b..1e5de1eea1 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -563,6 +563,7 @@ OIDC: DefaultBackChannelLogoutLifetime: 15m # ZITADEL_OIDC_DEFAULTBACKCHANNELLOGOUTLIFETIME SAML: + DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 ProviderConfig: MetadataConfig: Path: "/metadata" # ZITADEL_SAML_PROVIDERCONFIG_METADATACONFIG_PATH diff --git a/cmd/start/start.go b/cmd/start/start.go index 61e9c35e34..72ab9ea862 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -49,6 +49,7 @@ import ( user_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/resources/user/v3alpha" userschema_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/resources/userschema/v3alpha" "github.com/zitadel/zitadel/internal/api/grpc/resources/webkey/v3" + saml_v2 "github.com/zitadel/zitadel/internal/api/grpc/saml/v2" session_v2 "github.com/zitadel/zitadel/internal/api/grpc/session/v2" session_v2beta "github.com/zitadel/zitadel/internal/api/grpc/session/v2beta" settings_v2 "github.com/zitadel/zitadel/internal/api/grpc/settings/v2" @@ -530,7 +531,7 @@ func startAPIs( store, consolePath, oidcServer.AuthCallbackURL(), - provider.AuthCallbackURL(samlProvider), + samlProvider.AuthCallbackURL(), config.ExternalSecure, userAgentInterceptor, op.NewIssuerInterceptor(oidcServer.IssuerFromRequest).Handler, @@ -555,6 +556,10 @@ func startAPIs( if err := apis.RegisterService(ctx, oidc_v2.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil { return nil, err } + // After SAML provider so that the callback endpoint can be used + if err := apis.RegisterService(ctx, saml_v2.CreateServer(commands, queries, samlProvider, config.ExternalSecure)); err != nil { + return nil, err + } // handle grpc at last to be able to handle the root, because grpc and gateway require a lot of different prefixes apis.RouteGRPC() return apis, nil diff --git a/docs/docs/guides/integrate/zitadel-apis/event-api.md b/docs/docs/guides/integrate/zitadel-apis/event-api.md index c79cb27e8e..1c9ec82373 100644 --- a/docs/docs/guides/integrate/zitadel-apis/event-api.md +++ b/docs/docs/guides/integrate/zitadel-apis/event-api.md @@ -142,12 +142,27 @@ curl --request POST \ }' ``` - +```bash +curl --request POST \ + --url $CUSTOM-DOMAIN/admin/v1/events/_search \ + --header "Authorization: Bearer $TOKEN" \ + --header 'Content-Type: application/json' \ + --data '{ + "asc": true, + "limit": 1000, + "eventTypes": [ + "saml_session.added", + "saml_session.saml_response.added" + ], + "aggregateTypes": [ + "saml_session" + ] +}' +``` ## Example: Get failed login attempt diff --git a/go.mod b/go.mod index cf5cbf919d..43056fd238 100644 --- a/go.mod +++ b/go.mod @@ -67,7 +67,7 @@ require ( github.com/zitadel/logging v0.6.1 github.com/zitadel/oidc/v3 v3.32.0 github.com/zitadel/passwap v0.6.0 - github.com/zitadel/saml v0.2.0 + github.com/zitadel/saml v0.3.3 github.com/zitadel/schema v1.3.0 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 diff --git a/go.sum b/go.sum index bacef90c1a..4c8751ce48 100644 --- a/go.sum +++ b/go.sum @@ -743,8 +743,8 @@ github.com/zitadel/oidc/v3 v3.32.0 h1:Mw0EPZRC6h+OXAuT0Uk2BZIjJQNHLqUpaJCm6c3IBy github.com/zitadel/oidc/v3 v3.32.0/go.mod h1:DyE/XClysRK/ozFaZSqlYamKVnTh4l6Ln25ihSNI03w= github.com/zitadel/passwap v0.6.0 h1:m9F3epFC0VkBXu25rihSLGyHvWiNlCzU5kk8RoI+SXQ= github.com/zitadel/passwap v0.6.0/go.mod h1:kqAiJ4I4eZvm3Y6oAk6hlEqlZZOkjMHraGXF90GG7LI= -github.com/zitadel/saml v0.2.0 h1:vv7r+Xz43eAPCb+fImMaospD+TWRZQDkb78AbSJRcL4= -github.com/zitadel/saml v0.2.0/go.mod h1:QqKcguOt7mMVI6tkEfpkyzwnYRdlmn3kYQj3VTPUw1g= +github.com/zitadel/saml v0.3.3 h1:Cn+1ZNeWlzMM7wxUxJfgNjXSW+Yu6UD4zWbpySA5GQM= +github.com/zitadel/saml v0.3.3/go.mod h1:QqKcguOt7mMVI6tkEfpkyzwnYRdlmn3kYQj3VTPUw1g= github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= diff --git a/internal/api/grpc/saml/v2/integration/saml_test.go b/internal/api/grpc/saml/v2/integration/saml_test.go new file mode 100644 index 0000000000..b70099fb20 --- /dev/null +++ b/internal/api/grpc/saml/v2/integration/saml_test.go @@ -0,0 +1,367 @@ +//go:build integration + +package saml_test + +import ( + "context" + "net/url" + "os" + "regexp" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/crewjam/saml" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" + saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +var ( + CTX context.Context + Instance *integration.Instance + Client saml_pb.SAMLServiceClient +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + Instance = integration.NewInstance(ctx) + Client = Instance.Client.SAMLv2 + + CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + return m.Run() + }()) +} + +func TestServer_GetAuthRequest(t *testing.T) { + rootURL := "https://sp.example.com" + idpMetadata, err := Instance.GetSAMLIDPMetadata() + require.NoError(t, err) + spMiddlewareRedirect, err := integration.CreateSAMLSP(rootURL, idpMetadata, saml.HTTPRedirectBinding) + require.NoError(t, err) + spMiddlewarePost, err := integration.CreateSAMLSP(rootURL, idpMetadata, saml.HTTPPostBinding) + require.NoError(t, err) + + acsRedirect := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[0] + acsPost := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[1] + + project, err := Instance.CreateProject(CTX) + require.NoError(t, err) + _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewareRedirect) + require.NoError(t, err) + _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewarePost) + require.NoError(t, err) + + now := time.Now() + + tests := []struct { + name string + dep func() (string, error) + want *oidc_pb.GetAuthRequestResponse + wantErr bool + }{ + { + name: "Not found", + dep: func() (string, error) { + return "123", nil + }, + wantErr: true, + }, + { + name: "success, redirect binding", + dep: func() (string, error) { + return Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) + }, + }, + { + name: "success, post binding", + dep: func() (string, error) { + return Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authRequestID, err := tt.dep() + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := Client.GetSAMLRequest(CTX, &saml_pb.GetSAMLRequestRequest{ + SamlRequestId: authRequestID, + }) + if tt.wantErr { + assert.Error(ttt, err) + return + } + assert.NoError(ttt, err) + authRequest := got.GetSamlRequest() + assert.NotNil(ttt, authRequest) + assert.Equal(ttt, authRequestID, authRequest.GetId()) + assert.WithinRange(ttt, authRequest.GetCreationDate().AsTime(), now.Add(-time.Second), now.Add(time.Second)) + }, retryDuration, tick, "timeout waiting for expected saml request result") + }) + } +} + +func TestServer_CreateResponse(t *testing.T) { + idpMetadata, err := Instance.GetSAMLIDPMetadata() + require.NoError(t, err) + rootURLRedirect := "spredirect.example.com" + spMiddlewareRedirect, err := integration.CreateSAMLSP("https://"+rootURLRedirect, idpMetadata, saml.HTTPRedirectBinding) + require.NoError(t, err) + rootURLPost := "sppost.example.com" + spMiddlewarePost, err := integration.CreateSAMLSP("https://"+rootURLPost, idpMetadata, saml.HTTPPostBinding) + require.NoError(t, err) + + acsRedirect := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[0] + acsPost := idpMetadata.IDPSSODescriptors[0].SingleSignOnServices[1] + + project, err := Instance.CreateProject(CTX) + require.NoError(t, err) + _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewareRedirect) + require.NoError(t, err) + _, err = Instance.CreateSAMLClient(CTX, project.GetId(), spMiddlewarePost) + require.NoError(t, err) + + sessionResp, err := Instance.Client.SessionV2.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: Instance.Users[integration.UserTypeOrgOwner].ID, + }, + }, + }, + }) + require.NoError(t, err) + + tests := []struct { + name string + req *saml_pb.CreateResponseRequest + AuthError string + want *saml_pb.CreateResponseResponse + wantURL *url.URL + wantErr bool + }{ + { + name: "Not found", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: "123", + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + wantErr: true, + }, + { + name: "session not found", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) + require.NoError(t, err) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: "foo", + SessionToken: "bar", + }, + }, + }, + wantErr: true, + }, + { + name: "session token invalid", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) + require.NoError(t, err) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: "bar", + }, + }, + }, + wantErr: true, + }, + { + name: "fail callback, post", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + require.NoError(t, err) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Error{ + Error: &saml_pb.AuthorizationError{ + Error: saml_pb.ErrorReason_ERROR_REASON_REQUEST_DENIED, + ErrorDescription: gu.Ptr("nope"), + }, + }, + }, + want: &saml_pb.CreateResponseResponse{ + Url: regexp.QuoteMeta(`https://` + rootURLPost + `/saml/acs`), + Binding: &saml_pb.CreateResponseResponse_Post{Post: &saml_pb.PostResponse{ + RelayState: "notempty", + SamlResponse: "notempty", + }}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "fail callback, post, already failed", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + require.NoError(t, err) + Instance.FailSAMLAuthRequest(CTX, authRequestID, saml_pb.ErrorReason_ERROR_REASON_AUTH_N_FAILED) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Error{ + Error: &saml_pb.AuthorizationError{ + Error: saml_pb.ErrorReason_ERROR_REASON_REQUEST_DENIED, + ErrorDescription: gu.Ptr("nope"), + }, + }, + }, + wantErr: true, + }, + { + name: "fail callback, redirect", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + require.NoError(t, err) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Error{ + Error: &saml_pb.AuthorizationError{ + Error: saml_pb.ErrorReason_ERROR_REASON_REQUEST_DENIED, + ErrorDescription: gu.Ptr("nope"), + }, + }, + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/` + rootURLRedirect + `\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "callback, redirect", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewareRedirect, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, gofakeit.BitcoinAddress(), saml.HTTPRedirectBinding) + require.NoError(t, err) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + want: &saml_pb.CreateResponseResponse{ + Url: `https:\/\/` + rootURLRedirect + `\/saml\/acs\?RelayState=(.*)&SAMLResponse=(.*)&SigAlg=(.*)&Signature=(.*)`, + Binding: &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "callback, post", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + require.NoError(t, err) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + want: &saml_pb.CreateResponseResponse{ + Url: regexp.QuoteMeta(`https://` + rootURLPost + `/saml/acs`), + Binding: &saml_pb.CreateResponseResponse_Post{Post: &saml_pb.PostResponse{ + RelayState: "notempty", + SamlResponse: "notempty", + }}, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + }, + wantErr: false, + }, + { + name: "callback, post", + req: &saml_pb.CreateResponseRequest{ + SamlRequestId: func() string { + authRequestID, err := Instance.CreateSAMLAuthRequest(spMiddlewarePost, Instance.Users[integration.UserTypeOrgOwner].ID, acsPost, gofakeit.BitcoinAddress(), saml.HTTPPostBinding) + require.NoError(t, err) + Instance.SuccessfulSAMLAuthRequest(CTX, Instance.Users[integration.UserTypeOrgOwner].ID, authRequestID) + return authRequestID + }(), + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.CreateResponse(CTX, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + if tt.want != nil { + assert.Regexp(t, regexp.MustCompile(tt.want.Url), got.GetUrl()) + if tt.want.GetPost() != nil { + assert.NotEmpty(t, got.GetPost().GetRelayState()) + assert.NotEmpty(t, got.GetPost().GetSamlResponse()) + } + if tt.want.GetRedirect() != nil { + assert.NotNil(t, got.GetRedirect()) + } + } + }) + } +} diff --git a/internal/api/grpc/saml/v2/saml.go b/internal/api/grpc/saml/v2/saml.go new file mode 100644 index 0000000000..de4f3440ab --- /dev/null +++ b/internal/api/grpc/saml/v2/saml.go @@ -0,0 +1,112 @@ +package saml + +import ( + "context" + + "github.com/zitadel/logging" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/api/saml" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" +) + +func (s *Server) GetAuthRequest(ctx context.Context, req *saml_pb.GetSAMLRequestRequest) (*saml_pb.GetSAMLRequestResponse, error) { + authRequest, err := s.query.SamlRequestByID(ctx, true, req.GetSamlRequestId(), true) + if err != nil { + logging.WithError(err).Error("query samlRequest by ID") + return nil, err + } + return &saml_pb.GetSAMLRequestResponse{ + SamlRequest: samlRequestToPb(authRequest), + }, nil +} + +func samlRequestToPb(a *query.SamlRequest) *saml_pb.SAMLRequest { + return &saml_pb.SAMLRequest{ + Id: a.ID, + CreationDate: timestamppb.New(a.CreationDate), + } +} + +func (s *Server) CreateResponse(ctx context.Context, req *saml_pb.CreateResponseRequest) (*saml_pb.CreateResponseResponse, error) { + switch v := req.GetResponseKind().(type) { + case *saml_pb.CreateResponseRequest_Error: + return s.failSAMLRequest(ctx, req.GetSamlRequestId(), v.Error) + case *saml_pb.CreateResponseRequest_Session: + return s.linkSessionToSAMLRequest(ctx, req.GetSamlRequestId(), v.Session) + default: + return nil, zerrors.ThrowUnimplementedf(nil, "SAMLv2-0Tfak3fBS0", "verification oneOf %T in method CreateResponse not implemented", v) + } +} + +func (s *Server) failSAMLRequest(ctx context.Context, samlRequestID string, ae *saml_pb.AuthorizationError) (*saml_pb.CreateResponseResponse, error) { + details, aar, err := s.command.FailSAMLRequest(ctx, samlRequestID, errorReasonToDomain(ae.GetError())) + if err != nil { + return nil, err + } + authReq := &saml.AuthRequestV2{CurrentSAMLRequest: aar} + url, body, err := s.idp.CreateErrorResponse(authReq, errorReasonToDomain(ae.GetError()), ae.GetErrorDescription()) + if err != nil { + return nil, err + } + return createCallbackResponseFromBinding(details, url, body, authReq.RelayState), nil +} + +func (s *Server) linkSessionToSAMLRequest(ctx context.Context, samlRequestID string, session *saml_pb.Session) (*saml_pb.CreateResponseResponse, error) { + details, aar, err := s.command.LinkSessionToSAMLRequest(ctx, samlRequestID, session.GetSessionId(), session.GetSessionToken(), true) + if err != nil { + return nil, err + } + authReq := &saml.AuthRequestV2{CurrentSAMLRequest: aar} + url, body, err := s.idp.CreateResponse(ctx, authReq) + if err != nil { + return nil, err + } + return createCallbackResponseFromBinding(details, url, body, authReq.RelayState), nil +} + +func createCallbackResponseFromBinding(details *domain.ObjectDetails, url string, body string, relayState string) *saml_pb.CreateResponseResponse { + resp := &saml_pb.CreateResponseResponse{ + Details: object.DomainToDetailsPb(details), + Url: url, + } + + if body != "" { + resp.Binding = &saml_pb.CreateResponseResponse_Post{ + Post: &saml_pb.PostResponse{ + RelayState: relayState, + SamlResponse: body, + }, + } + } else { + resp.Binding = &saml_pb.CreateResponseResponse_Redirect{Redirect: &saml_pb.RedirectResponse{}} + } + return resp +} + +func errorReasonToDomain(errorReason saml_pb.ErrorReason) domain.SAMLErrorReason { + switch errorReason { + case saml_pb.ErrorReason_ERROR_REASON_UNSPECIFIED: + return domain.SAMLErrorReasonUnspecified + case saml_pb.ErrorReason_ERROR_REASON_VERSION_MISSMATCH: + return domain.SAMLErrorReasonVersionMissmatch + case saml_pb.ErrorReason_ERROR_REASON_AUTH_N_FAILED: + return domain.SAMLErrorReasonAuthNFailed + case saml_pb.ErrorReason_ERROR_REASON_INVALID_ATTR_NAME_OR_VALUE: + return domain.SAMLErrorReasonInvalidAttrNameOrValue + case saml_pb.ErrorReason_ERROR_REASON_INVALID_NAMEID_POLICY: + return domain.SAMLErrorReasonInvalidNameIDPolicy + case saml_pb.ErrorReason_ERROR_REASON_REQUEST_DENIED: + return domain.SAMLErrorReasonRequestDenied + case saml_pb.ErrorReason_ERROR_REASON_REQUEST_UNSUPPORTED: + return domain.SAMLErrorReasonRequestUnsupported + case saml_pb.ErrorReason_ERROR_REASON_UNSUPPORTED_BINDING: + return domain.SAMLErrorReasonUnsupportedBinding + default: + return domain.SAMLErrorReasonUnspecified + } +} diff --git a/internal/api/grpc/saml/v2/server.go b/internal/api/grpc/saml/v2/server.go new file mode 100644 index 0000000000..62299d88c5 --- /dev/null +++ b/internal/api/grpc/saml/v2/server.go @@ -0,0 +1,59 @@ +package saml + +import ( + "google.golang.org/grpc" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/api/saml" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/query" + saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" +) + +var _ saml_pb.SAMLServiceServer = (*Server)(nil) + +type Server struct { + saml_pb.UnimplementedSAMLServiceServer + command *command.Commands + query *query.Queries + + idp *saml.Provider + externalSecure bool +} + +type Config struct{} + +func CreateServer( + command *command.Commands, + query *query.Queries, + idp *saml.Provider, + externalSecure bool, +) *Server { + return &Server{ + command: command, + query: query, + idp: idp, + externalSecure: externalSecure, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + saml_pb.RegisterSAMLServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return saml_pb.SAMLService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return saml_pb.SAMLService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return saml_pb.SAMLService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return saml_pb.RegisterSAMLServiceHandler +} diff --git a/internal/api/saml/auth_request.go b/internal/api/saml/auth_request.go new file mode 100644 index 0000000000..a846cd090b --- /dev/null +++ b/internal/api/saml/auth_request.go @@ -0,0 +1,99 @@ +package saml + +import ( + "context" + "encoding/base64" + "net/url" + + "github.com/zitadel/saml/pkg/provider" + "github.com/zitadel/saml/pkg/provider/models" + "github.com/zitadel/saml/pkg/provider/xml" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" +) + +func (p *Provider) CreateErrorResponse(authReq models.AuthRequestInt, reason domain.SAMLErrorReason, description string) (string, string, error) { + resp := &provider.Response{ + ProtocolBinding: authReq.GetBindingType(), + RelayState: authReq.GetRelayState(), + AcsUrl: authReq.GetAccessConsumerServiceURL(), + RequestID: authReq.GetAuthRequestID(), + Issuer: authReq.GetDestination(), + Audience: authReq.GetIssuer(), + } + return createResponse(p.AuthCallbackErrorResponse(resp, domain.SAMLErrorReasonToString(reason), description), authReq.GetBindingType(), authReq.GetAccessConsumerServiceURL(), resp.RelayState, resp.SigAlg, resp.Signature) +} + +func (p *Provider) CreateResponse(ctx context.Context, authReq models.AuthRequestInt) (string, string, error) { + resp := &provider.Response{ + ProtocolBinding: authReq.GetBindingType(), + RelayState: authReq.GetRelayState(), + AcsUrl: authReq.GetAccessConsumerServiceURL(), + RequestID: authReq.GetAuthRequestID(), + Issuer: authReq.GetDestination(), + Audience: authReq.GetIssuer(), + } + samlResponse, err := p.AuthCallbackResponse(ctx, authReq, resp) + if err != nil { + return "", "", err + } + + if err := p.command.CreateSAMLSessionFromSAMLRequest( + setContextUserSystem(ctx), + authReq.GetID(), + samlComplianceChecker(), + samlResponse.Id, + p.Expiration(), + ); err != nil { + return "", "", err + } + + return createResponse(samlResponse, authReq.GetBindingType(), authReq.GetAccessConsumerServiceURL(), resp.RelayState, resp.SigAlg, resp.Signature) +} + +func createResponse(samlResponse interface{}, binding, acs, relayState, sigAlg, sig string) (string, string, error) { + respData, err := xml.Marshal(samlResponse) + if err != nil { + return "", "", err + } + + switch binding { + case provider.PostBinding: + return acs, base64.StdEncoding.EncodeToString(respData), nil + case provider.RedirectBinding: + respData, err := xml.DeflateAndBase64(respData) + if err != nil { + return "", "", err + } + parsed, err := url.Parse(acs) + if err != nil { + return "", "", err + } + values := parsed.Query() + values.Add("SAMLResponse", string(respData)) + values.Add("RelayState", relayState) + values.Add("SigAlg", sigAlg) + values.Add("Signature", sig) + parsed.RawQuery = values.Encode() + return parsed.String(), "", nil + } + return "", "", nil +} + +func setContextUserSystem(ctx context.Context) context.Context { + data := authz.CtxData{ + UserID: "SYSTEM", + } + return authz.SetCtxData(ctx, data) +} + +func samlComplianceChecker() command.SAMLRequestComplianceChecker { + return func(_ context.Context, samlReq *command.SAMLRequestWriteModel) error { + if err := samlReq.CheckAuthenticated(); err != nil { + return err + } + return nil + } +} diff --git a/internal/api/saml/auth_request_converter_v2.go b/internal/api/saml/auth_request_converter_v2.go new file mode 100644 index 0000000000..d392734c73 --- /dev/null +++ b/internal/api/saml/auth_request_converter_v2.go @@ -0,0 +1,45 @@ +package saml + +import ( + "github.com/zitadel/saml/pkg/provider/models" + + "github.com/zitadel/zitadel/internal/command" +) + +var _ models.AuthRequestInt = &AuthRequestV2{} + +type AuthRequestV2 struct { + *command.CurrentSAMLRequest +} + +func (a *AuthRequestV2) GetApplicationID() string { + return a.ApplicationID +} + +func (a *AuthRequestV2) GetID() string { + return a.ID +} +func (a *AuthRequestV2) GetRelayState() string { + return a.RelayState +} +func (a *AuthRequestV2) GetAccessConsumerServiceURL() string { + return a.ACSURL +} +func (a *AuthRequestV2) GetAuthRequestID() string { + return a.RequestID +} +func (a *AuthRequestV2) GetBindingType() string { + return a.Binding +} +func (a *AuthRequestV2) GetIssuer() string { + return a.Issuer +} +func (a *AuthRequestV2) GetDestination() string { + return a.Destination +} +func (a *AuthRequestV2) GetUserID() string { + return a.UserID +} +func (a *AuthRequestV2) Done() bool { + return a.UserID != "" && a.SessionID != "" +} diff --git a/internal/api/saml/provider.go b/internal/api/saml/provider.go index 4622ad5832..edf713456c 100644 --- a/internal/api/saml/provider.go +++ b/internal/api/saml/provider.go @@ -24,7 +24,13 @@ const ( ) type Config struct { - ProviderConfig *provider.Config + ProviderConfig *provider.Config + DefaultLoginURLV2 string +} + +type Provider struct { + *provider.Provider + command *command.Commands } func NewProvider( @@ -40,7 +46,7 @@ func NewProvider( instanceHandler, userAgentCookie func(http.Handler) http.Handler, accessHandler *middleware.AccessInterceptor, -) (*provider.Provider, error) { +) (*Provider, error) { metricTypes := []metrics.MetricType{metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode, metrics.MetricTypeTotalCount} provStorage, err := newStorage( @@ -51,6 +57,8 @@ func NewProvider( certEncAlg, es, projections, + fmt.Sprintf("%s%s?%s=", login.HandlerPrefix, login.EndpointLogin, login.QueryAuthRequestID), + conf.DefaultLoginURLV2, ) if err != nil { return nil, err @@ -73,12 +81,19 @@ func NewProvider( options = append(options, provider.WithAllowInsecure()) } - return provider.NewProvider( + p, err := provider.NewProvider( provStorage, HandlerPrefix, conf.ProviderConfig, options..., ) + if err != nil { + return nil, err + } + return &Provider{ + p, + command, + }, nil } func newStorage( @@ -89,16 +104,19 @@ func newStorage( certEncAlg crypto.EncryptionAlgorithm, es *eventstore.Eventstore, db *database.DB, + defaultLoginURL string, + defaultLoginURLV2 string, ) (*Storage, error) { return &Storage{ - encAlg: encAlg, - certEncAlg: certEncAlg, - locker: crdb.NewLocker(db.DB, locksTable, signingKey), - eventstore: es, - repo: repo, - command: command, - query: query, - defaultLoginURL: fmt.Sprintf("%s%s?%s=", login.HandlerPrefix, login.EndpointLogin, login.QueryAuthRequestID), + encAlg: encAlg, + certEncAlg: certEncAlg, + locker: crdb.NewLocker(db.DB, locksTable, signingKey), + eventstore: es, + repo: repo, + command: command, + query: query, + defaultLoginURL: defaultLoginURL, + defaultLoginURLv2: defaultLoginURLV2, }, nil } diff --git a/internal/api/saml/storage.go b/internal/api/saml/storage.go index 76173c2592..76f1bfd903 100644 --- a/internal/api/saml/storage.go +++ b/internal/api/saml/storage.go @@ -3,6 +3,7 @@ package saml import ( "context" "encoding/json" + "strings" "time" "github.com/dop251/goja" @@ -16,6 +17,7 @@ import ( "github.com/zitadel/zitadel/internal/actions" "github.com/zitadel/zitadel/internal/actions/object" "github.com/zitadel/zitadel/internal/activity" + http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/auth/repository" "github.com/zitadel/zitadel/internal/command" @@ -33,6 +35,10 @@ var _ provider.IdentityProviderStorage = &Storage{} var _ provider.AuthStorage = &Storage{} var _ provider.UserStorage = &Storage{} +const ( + LoginClientHeader = "x-zitadel-login-client" +) + type Storage struct { certChan <-chan interface{} defaultCertificateLifetime time.Duration @@ -51,7 +57,8 @@ type Storage struct { command *command.Commands query *query.Queries - defaultLoginURL string + defaultLoginURL string + defaultLoginURLv2 string } func (p *Storage) GetEntityByID(ctx context.Context, entityID string) (*serviceprovider.ServiceProvider, error) { @@ -64,7 +71,12 @@ func (p *Storage) GetEntityByID(ctx context.Context, entityID string) (*servicep &serviceprovider.Config{ Metadata: app.SAMLConfig.Metadata, }, - p.defaultLoginURL, + func(id string) string { + if strings.HasPrefix(id, command.IDPrefixV2) { + return p.defaultLoginURLv2 + id + } + return p.defaultLoginURL + id + }, ) } @@ -95,6 +107,38 @@ func (p *Storage) GetResponseSigningKey(ctx context.Context) (*key.CertificateAn func (p *Storage) CreateAuthRequest(ctx context.Context, req *samlp.AuthnRequestType, acsUrl, protocolBinding, relayState, applicationID string) (_ models.AuthRequestInt, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() + + headers, _ := http_utils.HeadersFromCtx(ctx) + if loginClient := headers.Get(LoginClientHeader); loginClient != "" { + return p.createAuthRequestLoginClient(ctx, req, acsUrl, protocolBinding, relayState, applicationID, loginClient) + } + return p.createAuthRequest(ctx, req, acsUrl, protocolBinding, relayState, applicationID) +} + +func (p *Storage) createAuthRequestLoginClient(ctx context.Context, req *samlp.AuthnRequestType, acsUrl, protocolBinding, relayState, applicationID, loginClient string) (_ models.AuthRequestInt, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + samlRequest := &command.SAMLRequest{ + ApplicationID: applicationID, + ACSURL: acsUrl, + RelayState: relayState, + RequestID: req.Id, + Binding: protocolBinding, + Issuer: req.Issuer.Text, + Destination: req.Destination, + LoginClient: loginClient, + } + + aar, err := p.command.AddSAMLRequest(ctx, samlRequest) + if err != nil { + return nil, err + } + return &AuthRequestV2{aar}, nil +} + +func (p *Storage) createAuthRequest(ctx context.Context, req *samlp.AuthnRequestType, acsUrl, protocolBinding, relayState, applicationID string) (_ models.AuthRequestInt, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() userAgentID, ok := middleware.UserAgentIDFromCtx(ctx) if !ok { return nil, zerrors.ThrowPreconditionFailed(nil, "SAML-sd436", "no user agent id") @@ -113,6 +157,15 @@ func (p *Storage) CreateAuthRequest(ctx context.Context, req *samlp.AuthnRequest func (p *Storage) AuthRequestByID(ctx context.Context, id string) (_ models.AuthRequestInt, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() + + if strings.HasPrefix(id, command.IDPrefixV2) { + req, err := p.command.GetCurrentSAMLRequest(ctx, id) + if err != nil { + return nil, err + } + return &AuthRequestV2{req}, nil + } + userAgentID, ok := middleware.UserAgentIDFromCtx(ctx) if !ok { return nil, zerrors.ThrowPreconditionFailed(nil, "SAML-D3g21", "no user agent id") diff --git a/internal/command/saml_request.go b/internal/command/saml_request.go new file mode 100644 index 0000000000..9d12ba6e44 --- /dev/null +++ b/internal/command/saml_request.go @@ -0,0 +1,161 @@ +package command + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/repository/samlrequest" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type SAMLRequest struct { + ID string + LoginClient string + + ApplicationID string + ACSURL string + RelayState string + RequestID string + Binding string + Issuer string + Destination string +} + +type CurrentSAMLRequest struct { + *SAMLRequest + SessionID string + UserID string + AuthMethods []domain.UserAuthMethodType + AuthTime time.Time +} + +func (c *Commands) AddSAMLRequest(ctx context.Context, samlRequest *SAMLRequest) (_ *CurrentSAMLRequest, err error) { + id, err := c.idGenerator.Next() + if err != nil { + return nil, err + } + samlRequest.ID = IDPrefixV2 + id + writeModel, err := c.getSAMLRequestWriteModel(ctx, samlRequest.ID) + if err != nil { + return nil, err + } + if writeModel.SAMLRequestState != domain.SAMLRequestStateUnspecified { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-MO3vmsMLUt", "Errors.SAMLRequest.AlreadyExisting") + } + err = c.pushAppendAndReduce(ctx, writeModel, samlrequest.NewAddedEvent( + ctx, + &samlrequest.NewAggregate(samlRequest.ID, authz.GetInstance(ctx).InstanceID()).Aggregate, + samlRequest.LoginClient, + samlRequest.ApplicationID, + samlRequest.ACSURL, + samlRequest.RelayState, + samlRequest.RequestID, + samlRequest.Binding, + samlRequest.Issuer, + samlRequest.Destination, + )) + if err != nil { + return nil, err + } + return samlRequestWriteModelToCurrentSAMLRequest(writeModel), nil +} + +func (c *Commands) LinkSessionToSAMLRequest(ctx context.Context, id, sessionID, sessionToken string, checkLoginClient bool) (*domain.ObjectDetails, *CurrentSAMLRequest, error) { + writeModel, err := c.getSAMLRequestWriteModel(ctx, id) + if err != nil { + return nil, nil, err + } + if writeModel.SAMLRequestState == domain.SAMLRequestStateUnspecified { + return nil, nil, zerrors.ThrowNotFound(nil, "COMMAND-GH3PVLSfXC", "Errors.SAMLRequest.NotExisting") + } + if writeModel.SAMLRequestState != domain.SAMLRequestStateAdded { + return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-ttPKNdAIFT", "Errors.SAMLRequest.AlreadyHandled") + } + if checkLoginClient && authz.GetCtxData(ctx).UserID != writeModel.LoginClient { + return nil, nil, zerrors.ThrowPermissionDenied(nil, "COMMAND-KCd48Rxt7x", "Errors.SAMLRequest.WrongLoginClient") + } + sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetInstance(ctx).InstanceID()) + err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel) + if err != nil { + return nil, nil, err + } + if err = sessionWriteModel.CheckIsActive(); err != nil { + return nil, nil, err + } + if err := c.sessionTokenVerifier(ctx, sessionToken, sessionWriteModel.AggregateID, sessionWriteModel.TokenID); err != nil { + return nil, nil, err + } + + if err := c.pushAppendAndReduce(ctx, writeModel, samlrequest.NewSessionLinkedEvent( + ctx, &samlrequest.NewAggregate(id, authz.GetInstance(ctx).InstanceID()).Aggregate, + sessionID, + sessionWriteModel.UserID, + sessionWriteModel.AuthenticationTime(), + sessionWriteModel.AuthMethodTypes(), + )); err != nil { + return nil, nil, err + } + return writeModelToObjectDetails(&writeModel.WriteModel), samlRequestWriteModelToCurrentSAMLRequest(writeModel), nil +} + +func (c *Commands) FailSAMLRequest(ctx context.Context, id string, reason domain.SAMLErrorReason) (*domain.ObjectDetails, *CurrentSAMLRequest, error) { + writeModel, err := c.getSAMLRequestWriteModel(ctx, id) + if err != nil { + return nil, nil, err + } + if writeModel.SAMLRequestState != domain.SAMLRequestStateAdded { + return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-32lGj1Fhjt", "Errors.SAMLRequest.AlreadyHandled") + } + err = c.pushAppendAndReduce(ctx, writeModel, samlrequest.NewFailedEvent( + ctx, + &samlrequest.NewAggregate(id, authz.GetInstance(ctx).InstanceID()).Aggregate, + reason, + )) + if err != nil { + return nil, nil, err + } + return writeModelToObjectDetails(&writeModel.WriteModel), samlRequestWriteModelToCurrentSAMLRequest(writeModel), nil +} + +func samlRequestWriteModelToCurrentSAMLRequest(writeModel *SAMLRequestWriteModel) (_ *CurrentSAMLRequest) { + return &CurrentSAMLRequest{ + SAMLRequest: &SAMLRequest{ + ID: writeModel.AggregateID, + LoginClient: writeModel.LoginClient, + ApplicationID: writeModel.ApplicationID, + ACSURL: writeModel.ACSURL, + RelayState: writeModel.RelayState, + RequestID: writeModel.RequestID, + Binding: writeModel.Binding, + Issuer: writeModel.Issuer, + Destination: writeModel.Destination, + }, + SessionID: writeModel.SessionID, + UserID: writeModel.UserID, + AuthMethods: writeModel.AuthMethods, + AuthTime: writeModel.AuthTime, + } +} + +func (c *Commands) GetCurrentSAMLRequest(ctx context.Context, id string) (_ *CurrentSAMLRequest, err error) { + wm, err := c.getSAMLRequestWriteModel(ctx, id) + if err != nil { + return nil, err + } + return samlRequestWriteModelToCurrentSAMLRequest(wm), nil +} + +func (c *Commands) getSAMLRequestWriteModel(ctx context.Context, id string) (writeModel *SAMLRequestWriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = NewSAMLRequestWriteModel(ctx, id) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, nil +} diff --git a/internal/command/saml_request_model.go b/internal/command/saml_request_model.go new file mode 100644 index 0000000000..7ba640cbe8 --- /dev/null +++ b/internal/command/saml_request_model.go @@ -0,0 +1,88 @@ +package command + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/samlrequest" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type SAMLRequestWriteModel struct { + eventstore.WriteModel + aggregate *eventstore.Aggregate + + LoginClient string + ApplicationID string + ACSURL string + RelayState string + RequestID string + Binding string + Issuer string + Destination string + + SessionID string + UserID string + AuthTime time.Time + AuthMethods []domain.UserAuthMethodType + SAMLRequestState domain.SAMLRequestState +} + +func NewSAMLRequestWriteModel(ctx context.Context, id string) *SAMLRequestWriteModel { + return &SAMLRequestWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: id, + }, + aggregate: &samlrequest.NewAggregate(id, authz.GetInstance(ctx).InstanceID()).Aggregate, + } +} + +func (m *SAMLRequestWriteModel) Reduce() error { + for _, event := range m.Events { + switch e := event.(type) { + case *samlrequest.AddedEvent: + m.LoginClient = e.LoginClient + m.ApplicationID = e.ApplicationID + m.ACSURL = e.ACSURL + m.RelayState = e.RelayState + m.RequestID = e.RequestID + m.Binding = e.Binding + m.Issuer = e.Issuer + m.Destination = e.Destination + m.SAMLRequestState = domain.SAMLRequestStateAdded + case *samlrequest.SessionLinkedEvent: + m.SessionID = e.SessionID + m.UserID = e.UserID + m.AuthTime = e.AuthTime + m.AuthMethods = e.AuthMethods + case *samlrequest.FailedEvent: + m.SAMLRequestState = domain.SAMLRequestStateFailed + case *samlrequest.SucceededEvent: + m.SAMLRequestState = domain.SAMLRequestStateSucceeded + } + } + return m.WriteModel.Reduce() +} + +func (m *SAMLRequestWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(samlrequest.AggregateType). + AggregateIDs(m.AggregateID). + Builder() +} + +// CheckAuthenticated checks that the auth request exists, a session must have been linked +func (m *SAMLRequestWriteModel) CheckAuthenticated() error { + if m.SessionID == "" { + return zerrors.ThrowPreconditionFailed(nil, "AUTHR-3dNRNwSYeC", "Errors.SAMLRequest.NotAuthenticated") + } + // check that the requests exists, but has not succeeded yet + if m.SAMLRequestState == domain.SAMLRequestStateAdded { + return nil + } + return zerrors.ThrowPreconditionFailed(nil, "AUTHR-krQV50AlnJ", "Errors.SAMLRequest.NotAuthenticated") +} diff --git a/internal/command/saml_request_test.go b/internal/command/saml_request_test.go new file mode 100644 index 0000000000..18b1c2a392 --- /dev/null +++ b/internal/command/saml_request_test.go @@ -0,0 +1,676 @@ +package command + +import ( + "context" + "net" + "net/http" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/samlrequest" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_AddSAMLRequest(t *testing.T) { + mockCtx := authz.NewMockContext("instanceID", "orgID", "loginClient") + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + } + type args struct { + ctx context.Context + request *SAMLRequest + } + tests := []struct { + name string + fields fields + args args + want *CurrentSAMLRequest + wantErr error + }{ + { + "already exists error", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + ), + idGenerator: mock.NewIDGeneratorExpectIDs(t, "id"), + }, + args{ + ctx: mockCtx, + request: &SAMLRequest{}, + }, + nil, + zerrors.ThrowPreconditionFailed(nil, "COMMAND-MO3vmsMLUt", "Errors.SAMLRequest.AlreadyExisting"), + }, + { + "added", + fields{ + eventstore: expectEventstore( + expectFilter(), + expectPush( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + idGenerator: mock.NewIDGeneratorExpectIDs(t, "id"), + }, + args{ + ctx: mockCtx, + request: &SAMLRequest{ + LoginClient: "login", + ApplicationID: "application", + ACSURL: "acs", + RelayState: "relaystate", + RequestID: "request", + Binding: "binding", + Issuer: "issuer", + Destination: "destination", + }, + }, + &CurrentSAMLRequest{ + SAMLRequest: &SAMLRequest{ + ID: "V2_id", + LoginClient: "login", + ApplicationID: "application", + ACSURL: "acs", + RelayState: "relaystate", + RequestID: "request", + Binding: "binding", + Issuer: "issuer", + Destination: "destination", + }, + }, + nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + } + got, err := c.AddSAMLRequest(tt.args.ctx, tt.args.request) + require.ErrorIs(t, tt.wantErr, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCommands_LinkSessionToSAMLRequest(t *testing.T) { + mockCtx := authz.NewMockContext("instanceID", "orgID", "loginClient") + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) + } + type args struct { + ctx context.Context + id string + sessionID string + sessionToken string + checkLoginClient bool + } + type res struct { + details *domain.ObjectDetails + authReq *CurrentSAMLRequest + wantErr error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "samlRequest not found", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: mockCtx, + id: "id", + sessionID: "sessionID", + }, + res{ + wantErr: zerrors.ThrowNotFound(nil, "COMMAND-GH3PVLSfXC", "Errors.SAMLRequest.NotExisting"), + }, + }, + { + "samlRequest not existing", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + eventFromEventPusher( + samlrequest.NewFailedEvent(mockCtx, &samlrequest.NewAggregate("id", "instanceID").Aggregate, + domain.SAMLErrorReasonUnspecified, + ), + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: mockCtx, + id: "id", + sessionID: "sessionID", + }, + res{ + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-ttPKNdAIFT", "Errors.SAMLRequest.AlreadyHandled"), + }, + }, + { + "wrong login client", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "orgID", "wrongLoginClient"), + id: "id", + sessionID: "sessionID", + sessionToken: "token", + checkLoginClient: true, + }, + res{ + wantErr: zerrors.ThrowPermissionDenied(nil, "COMMAND-KCd48Rxt7x", "Errors.SAMLRequest.WrongLoginClient"), + }, + }, + { + "session not existing", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + expectFilter(), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: mockCtx, + id: "V2_id", + sessionID: "sessionID", + }, + res{ + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Flk38", "Errors.Session.NotExisting"), + }, + }, + { + "session expired", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(mockCtx, + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + )), + eventFromEventPusher( + session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + "userID", "org1", testNow.Add(-5*time.Minute), &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow.Add(-5*time.Minute)), + ), + eventFromEventPusher( + session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + ), + }, + args{ + ctx: mockCtx, + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + }, + res{ + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Hkl3d", "Errors.Session.Expired"), + }, + }, + { + "invalid session token", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(mockCtx, + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + )), + ), + ), + tokenVerifier: newMockTokenVerifierInvalid(), + }, + args{ + ctx: mockCtx, + id: "V2_id", + sessionID: "sessionID", + sessionToken: "invalid", + }, + res{ + wantErr: zerrors.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid"), + }, + }, + { + "linked", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(mockCtx, + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + )), + eventFromEventPusher( + session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow), + ), + eventFromEventPusherWithCreationDateNow( + session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + expectPush( + samlrequest.NewSessionLinkedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: mockCtx, + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + }, + res{ + details: &domain.ObjectDetails{ResourceOwner: "instanceID"}, + authReq: &CurrentSAMLRequest{ + SAMLRequest: &SAMLRequest{ + ID: "V2_id", + LoginClient: "login", + ApplicationID: "application", + ACSURL: "acs", + RelayState: "relaystate", + RequestID: "request", + Binding: "binding", + Issuer: "issuer", + Destination: "destination", + }, + SessionID: "sessionID", + UserID: "userID", + AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + }, + }, + }, + { + "linked with login client check", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "loginClient", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(mockCtx, + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + )), + eventFromEventPusher( + session.NewUserCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + testNow), + ), + eventFromEventPusherWithCreationDateNow( + session.NewLifetimeSetEvent(mockCtx, &session.NewAggregate("sessionID", "instance1").Aggregate, + 2*time.Minute), + ), + ), + expectPush( + samlrequest.NewSessionLinkedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + tokenVerifier: newMockTokenVerifierValid(), + }, + args{ + ctx: authz.NewMockContext("instanceID", "orgID", "loginClient"), + id: "V2_id", + sessionID: "sessionID", + sessionToken: "token", + checkLoginClient: true, + }, + res{ + details: &domain.ObjectDetails{ResourceOwner: "instanceID"}, + authReq: &CurrentSAMLRequest{ + SAMLRequest: &SAMLRequest{ + ID: "V2_id", + LoginClient: "loginClient", + ApplicationID: "application", + ACSURL: "acs", + RelayState: "relaystate", + RequestID: "request", + Binding: "binding", + Issuer: "issuer", + Destination: "destination", + }, + SessionID: "sessionID", + UserID: "userID", + AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + sessionTokenVerifier: tt.fields.tokenVerifier, + } + details, got, err := c.LinkSessionToSAMLRequest(tt.args.ctx, tt.args.id, tt.args.sessionID, tt.args.sessionToken, tt.args.checkLoginClient) + require.ErrorIs(t, err, tt.res.wantErr) + assertObjectDetails(t, tt.res.details, details) + if err == nil { + assert.WithinRange(t, got.AuthTime, testNow, testNow) + got.AuthTime = time.Time{} + } + assert.Equal(t, tt.res.authReq, got) + }) + } +} + +func TestCommands_FailSAMLRequest(t *testing.T) { + mockCtx := authz.NewMockContext("instanceID", "orgID", "loginClient") + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + } + type args struct { + ctx context.Context + id string + reason domain.SAMLErrorReason + description string + } + type res struct { + details *domain.ObjectDetails + samlReq *CurrentSAMLRequest + wantErr error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "authRequest not existing", + fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args{ + ctx: mockCtx, + id: "foo", + reason: domain.SAMLErrorReasonAuthNFailed, + }, + res{ + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-32lGj1Fhjt", "Errors.SAMLRequest.AlreadyHandled"), + }, + }, { + "already failed", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + samlrequest.NewFailedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + domain.SAMLErrorReasonAuthNFailed, + ), + ), + ), + }, + args{ + ctx: mockCtx, + id: "V2_id", + reason: domain.SAMLErrorReasonAuthNFailed, + description: "desc", + }, + res{ + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-32lGj1Fhjt", "Errors.SAMLRequest.AlreadyHandled"), + }, + }, + { + "failed", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + "login", + "application", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + expectPush( + samlrequest.NewFailedEvent(mockCtx, &samlrequest.NewAggregate("V2_id", "instanceID").Aggregate, + domain.SAMLErrorReasonAuthNFailed, + ), + ), + ), + }, + args{ + ctx: mockCtx, + id: "V2_id", + reason: domain.SAMLErrorReasonAuthNFailed, + description: "desc", + }, + res{ + details: &domain.ObjectDetails{ResourceOwner: "instanceID"}, + samlReq: &CurrentSAMLRequest{ + SAMLRequest: &SAMLRequest{ + ID: "V2_id", + LoginClient: "login", + ApplicationID: "application", + ACSURL: "acs", + RelayState: "relaystate", + RequestID: "request", + Binding: "binding", + Issuer: "issuer", + Destination: "destination", + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + } + details, got, err := c.FailSAMLRequest(tt.args.ctx, tt.args.id, tt.args.reason) + require.ErrorIs(t, err, tt.res.wantErr) + assertObjectDetails(t, tt.res.details, details) + assert.Equal(t, tt.res.samlReq, got) + }) + } +} diff --git a/internal/command/saml_session.go b/internal/command/saml_session.go new file mode 100644 index 0000000000..6e0c37af9e --- /dev/null +++ b/internal/command/saml_session.go @@ -0,0 +1,186 @@ +package command + +import ( + "context" + "time" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/activity" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/repository/samlrequest" + "github.com/zitadel/zitadel/internal/repository/samlsession" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type SAMLSession struct { + SessionID string + SAMLResponseID string + EntityID string + UserID string + Audience []string + Expiration time.Time + AuthMethods []domain.UserAuthMethodType + AuthTime time.Time + PreferredLanguage *language.Tag + UserAgent *domain.UserAgent +} + +type SAMLRequestComplianceChecker func(context.Context, *SAMLRequestWriteModel) error + +func (c *Commands) CreateSAMLSessionFromSAMLRequest(ctx context.Context, samlReqId string, complianceCheck SAMLRequestComplianceChecker, samlResponseID string, samlResponseLifetime time.Duration) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if samlReqId == "" { + return zerrors.ThrowPreconditionFailed(nil, "COMMAND-0LxK6O31wH", "Errors.SAMLRequest.InvalidCode") + } + + samlReqModel, err := c.getSAMLRequestWriteModel(ctx, samlReqId) + if err != nil { + return err + } + + instanceID := authz.GetInstance(ctx).InstanceID() + sessionModel := NewSessionWriteModel(samlReqModel.SessionID, instanceID) + err = c.eventstore.FilterToQueryReducer(ctx, sessionModel) + if err != nil { + return err + } + if err = sessionModel.CheckIsActive(); err != nil { + return err + } + + cmd, err := c.newSAMLSessionAddEvents(ctx, sessionModel.UserID, sessionModel.UserResourceOwner) + if err != nil { + return err + } + if err = complianceCheck(ctx, samlReqModel); err != nil { + return err + } + + cmd.AddSession(ctx, + sessionModel.UserID, + sessionModel.UserResourceOwner, + sessionModel.AggregateID, + samlReqModel.Issuer, + []string{samlReqModel.Issuer}, + samlReqModel.AuthMethods, + samlReqModel.AuthTime, + sessionModel.PreferredLanguage, + sessionModel.UserAgent, + ) + + if err = cmd.AddSAMLResponse(ctx, samlResponseID, samlResponseLifetime); err != nil { + return err + } + cmd.SetSAMLRequestSuccessful(ctx, samlReqModel.aggregate) + _, err = cmd.PushEvents(ctx) + return err +} + +func (c *Commands) newSAMLSessionAddEvents(ctx context.Context, userID, resourceOwner string, pending ...eventstore.Command) (*SAMLSessionEvents, error) { + userStateModel, err := c.userStateWriteModel(ctx, userID) + if err != nil { + return nil, err + } + if !userStateModel.UserState.IsEnabled() { + return nil, zerrors.ThrowPreconditionFailed(nil, "SAML-1768ZQpmcP", "Errors.User.NotActive") + } + sessionID, err := c.idGenerator.Next() + if err != nil { + return nil, err + } + sessionID = IDPrefixV2 + sessionID + return &SAMLSessionEvents{ + commands: c, + idGenerator: c.idGenerator, + encryptionAlg: c.keyAlgorithm, + events: pending, + samlSessionWriteModel: NewSAMLSessionWriteModel(sessionID, resourceOwner), + userStateModel: userStateModel, + }, nil +} + +type SAMLSessionEvents struct { + commands *Commands + idGenerator id.Generator + encryptionAlg crypto.EncryptionAlgorithm + events []eventstore.Command + samlSessionWriteModel *SAMLSessionWriteModel + userStateModel *UserV2WriteModel + + // samlResponseID is set by the command + samlResponseID string +} + +func (c *SAMLSessionEvents) AddSession( + ctx context.Context, + userID, + userResourceOwner, + sessionID, + entityID string, + audience []string, + authMethods []domain.UserAuthMethodType, + authTime time.Time, + preferredLanguage *language.Tag, + userAgent *domain.UserAgent, +) { + c.events = append(c.events, samlsession.NewAddedEvent( + ctx, + c.samlSessionWriteModel.aggregate, + userID, + userResourceOwner, + sessionID, + entityID, + audience, + authMethods, + authTime, + preferredLanguage, + userAgent, + )) +} + +func (c *SAMLSessionEvents) SetSAMLRequestSuccessful(ctx context.Context, samlRequestAggregate *eventstore.Aggregate) { + c.events = append(c.events, samlrequest.NewSucceededEvent(ctx, samlRequestAggregate)) +} + +func (c *SAMLSessionEvents) SetSAMLRequestFailed(ctx context.Context, samlRequestAggregate *eventstore.Aggregate, err domain.SAMLErrorReason) { + c.events = append(c.events, samlrequest.NewFailedEvent(ctx, samlRequestAggregate, err)) +} + +func (c *SAMLSessionEvents) AddSAMLResponse(ctx context.Context, id string, lifetime time.Duration) error { + c.samlResponseID = id + c.events = append(c.events, samlsession.NewSAMLResponseAddedEvent(ctx, c.samlSessionWriteModel.aggregate, id, lifetime)) + return nil +} + +func (c *SAMLSessionEvents) PushEvents(ctx context.Context) (*SAMLSession, error) { + pushedEvents, err := c.commands.eventstore.Push(ctx, c.events...) + if err != nil { + return nil, err + } + err = AppendAndReduce(c.samlSessionWriteModel, pushedEvents...) + if err != nil { + return nil, err + } + session := &SAMLSession{ + SessionID: c.samlSessionWriteModel.SessionID, + EntityID: c.samlSessionWriteModel.EntityID, + UserID: c.samlSessionWriteModel.UserID, + Audience: c.samlSessionWriteModel.Audience, + Expiration: c.samlSessionWriteModel.SAMLResponseExpiration, + AuthMethods: c.samlSessionWriteModel.AuthMethods, + AuthTime: c.samlSessionWriteModel.AuthTime, + PreferredLanguage: c.samlSessionWriteModel.PreferredLanguage, + UserAgent: c.samlSessionWriteModel.UserAgent, + SAMLResponseID: c.samlSessionWriteModel.SAMLResponseID, + } + activity.Trigger(ctx, c.samlSessionWriteModel.UserResourceOwner, c.samlSessionWriteModel.UserID, activity.SAMLResponse, c.commands.eventstore.FilterToQueryReducer) + return session, nil +} diff --git a/internal/command/saml_session_model.go b/internal/command/saml_session_model.go new file mode 100644 index 0000000000..6c3b861492 --- /dev/null +++ b/internal/command/saml_session_model.go @@ -0,0 +1,102 @@ +package command + +import ( + "time" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/samlsession" +) + +type SAMLSessionWriteModel struct { + eventstore.WriteModel + + UserID string + UserResourceOwner string + PreferredLanguage *language.Tag + SessionID string + EntityID string + Audience []string + AuthMethods []domain.UserAuthMethodType + AuthTime time.Time + UserAgent *domain.UserAgent + State domain.SAMLSessionState + SAMLResponseID string + SAMLResponseCreation time.Time + SAMLResponseExpiration time.Time + + aggregate *eventstore.Aggregate +} + +func NewSAMLSessionWriteModel(id string, resourceOwner string) *SAMLSessionWriteModel { + return &SAMLSessionWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: id, + ResourceOwner: resourceOwner, + }, + aggregate: &samlsession.NewAggregate(id, resourceOwner).Aggregate, + } +} + +func (wm *SAMLSessionWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *samlsession.AddedEvent: + wm.reduceAdded(e) + case *samlsession.SAMLResponseAddedEvent: + wm.reduceSAMLResponseAdded(e) + case *samlsession.SAMLResponseRevokedEvent: + wm.reduceSAMLResponseRevoked(e) + } + } + return wm.WriteModel.Reduce() +} + +func (wm *SAMLSessionWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(samlsession.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + samlsession.AddedType, + samlsession.SAMLResponseAddedType, + samlsession.SAMLResponseRevokedType, + ). + Builder() + + if wm.ResourceOwner != "" { + query.ResourceOwner(wm.ResourceOwner) + } + return query +} + +func (wm *SAMLSessionWriteModel) reduceAdded(e *samlsession.AddedEvent) { + wm.UserID = e.UserID + wm.UserResourceOwner = e.UserResourceOwner + wm.SessionID = e.SessionID + wm.EntityID = e.EntityID + wm.Audience = e.Audience + wm.AuthMethods = e.AuthMethods + wm.AuthTime = e.AuthTime + wm.PreferredLanguage = e.PreferredLanguage + wm.UserAgent = e.UserAgent + wm.State = domain.SAMLSessionStateActive + // the write model might be initialized without resource owner, + // so update the aggregate + if wm.ResourceOwner == "" { + wm.aggregate = &samlsession.NewAggregate(wm.AggregateID, e.Aggregate().ResourceOwner).Aggregate + } +} + +func (wm *SAMLSessionWriteModel) reduceSAMLResponseAdded(e *samlsession.SAMLResponseAddedEvent) { + wm.SAMLResponseID = e.ID + wm.SAMLResponseCreation = e.CreationDate() + wm.SAMLResponseExpiration = e.CreationDate().Add(e.Lifetime) +} + +func (wm *SAMLSessionWriteModel) reduceSAMLResponseRevoked(e *samlsession.SAMLResponseRevokedEvent) { + wm.SAMLResponseID = "" + wm.SAMLResponseExpiration = e.CreationDate() +} diff --git a/internal/command/saml_session_test.go b/internal/command/saml_session_test.go new file mode 100644 index 0000000000..12cc0683c5 --- /dev/null +++ b/internal/command/saml_session_test.go @@ -0,0 +1,337 @@ +package command + +import ( + "context" + "io" + "net" + "net/http" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/samlrequest" + "github.com/zitadel/zitadel/internal/repository/samlsession" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func mockSAMLRequestComplianceChecker(returnErr error) SAMLRequestComplianceChecker { + return func(context.Context, *SAMLRequestWriteModel) error { + return returnErr + } +} + +func TestCommands_CreateSAMLSessionFromSAMLRequest(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + idGenerator id.Generator + keyAlgorithm crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + samlRequestID string + samlResponseID string + complianceCheck SAMLRequestComplianceChecker + samlResponseLifetime time.Duration + } + type res struct { + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "missing code", + fields{ + eventstore: expectEventstore(), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlRequestID: "", + complianceCheck: mockSAMLRequestComplianceChecker(nil), + }, + res{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-0LxK6O31wH", "Errors.SAMLRequest.InvalidCode"), + }, + }, + { + "filter error", + fields{ + eventstore: expectEventstore( + expectFilterError(io.ErrClosedPipe), + ), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlRequestID: "V2_samlRequestID", + complianceCheck: mockSAMLRequestComplianceChecker(nil), + }, + res{ + err: io.ErrClosedPipe, + }, + }, + { + "session filter error", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate, + "loginClient", + "applicationId", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + ), + expectFilterError(io.ErrClosedPipe), + ), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlRequestID: "V2_samlRequestID", + complianceCheck: mockSAMLRequestComplianceChecker(nil), + }, + res{ + err: io.ErrClosedPipe, + }, + }, + { + "inactive session error", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate, + "loginClient", + "applicationId", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + eventFromEventPusher( + samlrequest.NewSessionLinkedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + expectFilter(), // inactive session + ), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlRequestID: "V2_samlRequestID", + complianceCheck: mockSAMLRequestComplianceChecker(nil), + }, + res{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Flk38", "Errors.Session.NotExisting"), + }, + }, + { + "user not active", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate, + "loginClient", + "applicationId", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + eventFromEventPusher( + samlrequest.NewSessionLinkedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + ), + ), + eventFromEventPusher( + session.NewUserCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, + testNow), + ), + ), + expectFilter( + user.NewHumanAddedEvent( + context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + user.NewUserDeactivatedEvent( + context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + ), + idGenerator: mock.NewIDGeneratorExpectIDs(t), + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlRequestID: "V2_samlRequestID", + samlResponseID: "samlResponseID", + samlResponseLifetime: time.Minute * 5, + complianceCheck: mockSAMLRequestComplianceChecker(nil), + }, + res{ + err: zerrors.ThrowPreconditionFailed(nil, "SAML-1768ZQpmcP", "Errors.User.NotActive"), + }, + }, + { + "add successful", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + samlrequest.NewAddedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate, + "loginClient", + "applicationId", + "acs", + "relaystate", + "request", + "binding", + "issuer", + "destination", + ), + ), + eventFromEventPusher( + samlrequest.NewSessionLinkedEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate, + "sessionID", + "userID", + testNow, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), + &session.NewAggregate("sessionID", "instance1").Aggregate, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + ), + ), + eventFromEventPusher( + session.NewUserCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, + "userID", "org1", testNow, &language.Afrikaans), + ), + eventFromEventPusher( + session.NewPasswordCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate, + testNow), + ), + ), + expectFilter( + user.NewHumanAddedEvent( + context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + expectPush( + samlsession.NewAddedEvent(context.Background(), &samlsession.NewAggregate("V2_samlSessionID", "org1").Aggregate, + "userID", "org1", "sessionID", "issuer", []string{"issuer"}, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, &language.Afrikaans, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + ), + samlsession.NewSAMLResponseAddedEvent(context.Background(), &samlsession.NewAggregate("V2_samlSessionID", "org1").Aggregate, "samlResponseID", time.Minute*5), + samlrequest.NewSucceededEvent(context.Background(), &samlrequest.NewAggregate("V2_samlRequestID", "instanceID").Aggregate), + ), + ), + idGenerator: mock.NewIDGeneratorExpectIDs(t, "samlSessionID"), + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlRequestID: "V2_samlRequestID", + samlResponseID: "samlResponseID", + samlResponseLifetime: time.Minute * 5, + complianceCheck: mockSAMLRequestComplianceChecker(nil), + }, + res{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + keyAlgorithm: tt.fields.keyAlgorithm, + } + err := c.CreateSAMLSessionFromSAMLRequest(tt.args.ctx, tt.args.samlRequestID, tt.args.complianceCheck, tt.args.samlResponseID, tt.args.samlResponseLifetime) + require.ErrorIs(t, err, tt.res.err) + }) + } +} diff --git a/internal/domain/saml_error_reason.go b/internal/domain/saml_error_reason.go new file mode 100644 index 0000000000..a62c3590d7 --- /dev/null +++ b/internal/domain/saml_error_reason.go @@ -0,0 +1,41 @@ +package domain + +import ( + "github.com/zitadel/saml/pkg/provider" +) + +type SAMLErrorReason int32 + +const ( + SAMLErrorReasonUnspecified SAMLErrorReason = iota + SAMLErrorReasonVersionMissmatch + SAMLErrorReasonAuthNFailed + SAMLErrorReasonInvalidAttrNameOrValue + SAMLErrorReasonInvalidNameIDPolicy + SAMLErrorReasonRequestDenied + SAMLErrorReasonRequestUnsupported + SAMLErrorReasonUnsupportedBinding +) + +func SAMLErrorReasonToString(reason SAMLErrorReason) string { + switch reason { + case SAMLErrorReasonUnspecified: + return "unspecified error" + case SAMLErrorReasonVersionMissmatch: + return provider.StatusCodeVersionMissmatch + case SAMLErrorReasonAuthNFailed: + return provider.StatusCodeAuthNFailed + case SAMLErrorReasonInvalidAttrNameOrValue: + return provider.StatusCodeInvalidAttrNameOrValue + case SAMLErrorReasonInvalidNameIDPolicy: + return provider.StatusCodeInvalidNameIDPolicy + case SAMLErrorReasonRequestDenied: + return provider.StatusCodeRequestDenied + case SAMLErrorReasonRequestUnsupported: + return provider.StatusCodeRequestUnsupported + case SAMLErrorReasonUnsupportedBinding: + return provider.StatusCodeUnsupportedBinding + default: + return "unspecified error" + } +} diff --git a/internal/domain/saml_request.go b/internal/domain/saml_request.go new file mode 100644 index 0000000000..8cf13be544 --- /dev/null +++ b/internal/domain/saml_request.go @@ -0,0 +1,10 @@ +package domain + +type SAMLRequestState int + +const ( + SAMLRequestStateUnspecified SAMLRequestState = iota + SAMLRequestStateAdded + SAMLRequestStateFailed + SAMLRequestStateSucceeded +) diff --git a/internal/domain/saml_session.go b/internal/domain/saml_session.go new file mode 100644 index 0000000000..ccc968df4c --- /dev/null +++ b/internal/domain/saml_session.go @@ -0,0 +1,9 @@ +package domain + +type SAMLSessionState int32 + +const ( + SAMLSessionStateUnspecified SAMLSessionState = iota + SAMLSessionStateActive + SAMLSessionStateTerminated +) diff --git a/internal/integration/client.go b/internal/integration/client.go index e16bb1166f..34d4302ef4 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -34,6 +34,7 @@ import ( user_v3alpha "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" userschema_v3alpha "github.com/zitadel/zitadel/pkg/grpc/resources/userschema/v3alpha" webkey_v3alpha "github.com/zitadel/zitadel/pkg/grpc/resources/webkey/v3alpha" + saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" "github.com/zitadel/zitadel/pkg/grpc/session/v2" session_v2beta "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" @@ -65,6 +66,7 @@ type Client struct { WebKeyV3Alpha webkey_v3alpha.ZITADELWebKeysClient IDPv2 idp_pb.IdentityProviderServiceClient UserV3Alpha user_v3alpha.ZITADELUsersClient + SAMLv2 saml_pb.SAMLServiceClient } func newClient(ctx context.Context, target string) (*Client, error) { @@ -96,6 +98,7 @@ func newClient(ctx context.Context, target string) (*Client, error) { WebKeyV3Alpha: webkey_v3alpha.NewZITADELWebKeysClient(cc), IDPv2: idp_pb.NewIdentityProviderServiceClient(cc), UserV3Alpha: user_v3alpha.NewZITADELUsersClient(cc), + SAMLv2: saml_pb.NewSAMLServiceClient(cc), } return client, client.pollHealth(ctx) } diff --git a/internal/integration/saml.go b/internal/integration/saml.go new file mode 100644 index 0000000000..bf04246956 --- /dev/null +++ b/internal/integration/saml.go @@ -0,0 +1,223 @@ +package integration + +import ( + "context" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/brianvoe/gofakeit/v6" + "github.com/crewjam/saml" + "github.com/crewjam/saml/samlsp" + "github.com/zitadel/logging" + + http_util "github.com/zitadel/zitadel/internal/api/http" + oidc_internal "github.com/zitadel/zitadel/internal/api/oidc" + "github.com/zitadel/zitadel/pkg/grpc/management" + saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" + session_pb "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +const spCertificate = `-----BEGIN CERTIFICATE----- +MIIDITCCAgmgAwIBAgIUUo5urYkuUHAe7LQ9sZSL+xXAqBwwDQYJKoZIhvcNAQEL +BQAwIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1wbGUuY29tMB4XDTI0MTIwNDEz +MTE1MFoXDTI1MDEwMzEzMTE1MFowIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1w +bGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoACwbGIh8udK +Um1r+yQoPtfswEX6Cb6Y1KwR6WZDYgzHdMyUC5Sy8Bg1H2puUZZukDLuyu6Pqvum +8kfnzhjUR6nNCoUlidwE+yz020w5oOBofRKgJK/FVUuWD3k6kjdP9CrBFLG0PQQ3 +N2e4wilP4czCxizKero2a0e7Eq8OjHAPf8gjM+GWFZgVAbV8uf2Mjt1O2Vfbx5PZ +sLuBZtl5jokx3NiC7my/yj81MbGEDPcQo0emeVBz3J3nVG6Yr4kdCKkvv2dhJ26C +5cL7NIIUY4IQomJNwYC2NaYgSpQOxJHL/HsOPusO4Ia2WtUTXEZUFkxn1u0YuoSx +CkGehF/1OwIDAQABo1MwUTAdBgNVHQ4EFgQUr6S0wA2l3MdfnvfveWDueQtaoJMw +HwYDVR0jBBgwFoAUr6S0wA2l3MdfnvfveWDueQtaoJMwDwYDVR0TAQH/BAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAQEAH3Q9obyWJaMKFuGJDkIp1RFot79RWTVcAcwA +XTJNfCseLONRIs4MkRxOn6GQBwV2IEqs1+hFG80dcd/c6yYyJ8bziKEyNMtPWrl6 +fdVD+1WnWcD1ZYrS8hgdz0FxXxl/+GjA8Pu6icmnhKgUDTYWns6Rj/gtQtZS8ZoA +JY+T/1mGze2+Xx6pjuArZ7+hnH6EWwo+ckcmXAKyhnkhX7xIo1UFvNY2VWaGl2wU +K2yyJA4Lu/NNmqPnpAcRDsnGP6r4frMhjnPq/ifC3B+6FT3p8dubV9PA0y86bAy5 +0yIgNje4DyWLy/DM9EpdPfJmvUAL6hOtyb8Aa9hR+a8stu7h6g== +-----END CERTIFICATE-----` +const spKey = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCgALBsYiHy50pS +bWv7JCg+1+zARfoJvpjUrBHpZkNiDMd0zJQLlLLwGDUfam5Rlm6QMu7K7o+q+6by +R+fOGNRHqc0KhSWJ3AT7LPTbTDmg4Gh9EqAkr8VVS5YPeTqSN0/0KsEUsbQ9BDc3 +Z7jCKU/hzMLGLMp6ujZrR7sSrw6McA9/yCMz4ZYVmBUBtXy5/YyO3U7ZV9vHk9mw +u4Fm2XmOiTHc2ILubL/KPzUxsYQM9xCjR6Z5UHPcnedUbpiviR0IqS+/Z2EnboLl +wvs0ghRjghCiYk3BgLY1piBKlA7Ekcv8ew4+6w7ghrZa1RNcRlQWTGfW7Ri6hLEK +QZ6EX/U7AgMBAAECggEAD1aRkwpDO+BdORKhP9WDACc93F647fc1+mk2XFv/yKX1 +9uXnqUaLcsW3TfgrdCnKFouzZYPCBP+TzPUErTanHumRrNj/tLwBRDzWijE/8wKg +MaE39dxdu+P/kiMqcLrZsMvqb3vrjc/aJTcNuJsyO7Cf2VSQ4nv4XIdnUQ60A9VR +OmUp//VULZxImnPx/R304/p5VfOhyXfzBeoxUPogBurjtzkyXVG0EG2enJMMiTix +900fTDez0TQ8V6O59vM04fhtPXvH51OkMTW/HU1QQvlnAJuX06I7k4CaBpF3xPII +QpEbFILq5y6yAQJWELRGWzeoxK6kn6bNfI8S0+oKqQKBgQDg2UM7ruMASpY7B4zj +XNztGDOx9BCdYyHH1O05r+ILmltBC7jFImwIYrHbaX+dg52l0PPImZuBz852IqrC +VAEF30yBn2gWyVzIdo7W3mw9Jgqc4LrhStaJxOuXVoT2/PAuDBF8TJMNH9oLNqiD +aPAI0cVn9BRV7AziEsrMlDLLiQKBgQC2K4Z/caAvwx/AescsN6lp+/m7MeLUpZzQ +myZt44bnR5LouUo3vCYl+Bk8wu6PTd41LUYW/SW26HDDFTKgkBb1zVHfk5QRApaB +VPwZnhcUvNapPOnDp75Qoq238wpfayQlKF1xCawS3N5AWkDaEdfzuH7umFJxVss2 +1tfDsn01owKBgAYWG3nMHBzv5+0lIS0uYFSSqSOSBbkc69cq7lj3Z9kEjp/OH2xG +qEH52fKkgm3TGDta0p6Fee4jn+UWvySPfY+ZIcsIc5raTIaonuk2EBv/oZ3pf2WF +zxTfnbj1AJhm9GFqtjZ1JC3gxNg03I7iEk1K0FsmAj7pKtgbxh2PjWhxAoGBAKBx +BSwJbwOh3r0vZWvUOilV+0SbUyPmGI7Blr8BvTbFGuZNCsi7tP2L3O5e4Kzl7+b1 +0N0+Z5EIdwfaC5TOUup5wroeyDGTDesqZj5JthpVltnHBDuF6WArZsS0EVaojlUL +kACWfC7AyB31X1iwjnng7CpHjZS01JWf8rgw44XxAoGAQ5YYd4WmGYZoJJak7zhb +xnYG7hU7nS7pBPGob1FvjYMw1x/htuJCjxLh08dlzJGM6SFlDn7HVM9ou99w5n+d +xtqmbthw2E9VjSk3zSYb4uFc6mv0C/kRPTDUFH+9CpQTBBx/O016hmcatxlBS6JL +VAV6oE8sEJYHtR6YdZiMWWo= +-----END PRIVATE KEY-----` + +func CreateSAMLSP(root string, idpMetadata *saml.EntityDescriptor, binding string) (*samlsp.Middleware, error) { + rootURL, err := url.Parse(root) + if err != nil { + return nil, err + } + keyPair, err := tls.X509KeyPair([]byte(spCertificate), []byte(spKey)) + if err != nil { + return nil, err + } + keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) + if err != nil { + return nil, err + } + + sp, err := samlsp.New(samlsp.Options{ + URL: *rootURL, + Key: keyPair.PrivateKey.(*rsa.PrivateKey), + Certificate: keyPair.Leaf, + IDPMetadata: idpMetadata, + UseArtifactResponse: false, + }) + if err != nil { + return nil, err + } + sp.Binding = binding + sp.ResponseBinding = binding + return sp, nil +} + +func (i *Instance) CreateSAMLClient(ctx context.Context, projectID string, m *samlsp.Middleware) (*management.AddSAMLAppResponse, error) { + spMetadata, err := xml.MarshalIndent(m.ServiceProvider.Metadata(), "", " ") + if err != nil { + return nil, err + } + + if m.ResponseBinding == saml.HTTPRedirectBinding { + metadata := strings.Replace(string(spMetadata), saml.HTTPPostBinding, saml.HTTPRedirectBinding, 2) + spMetadata = []byte(metadata) + } + + resp, err := i.Client.Mgmt.AddSAMLApp(ctx, &management.AddSAMLAppRequest{ + ProjectId: projectID, + Name: fmt.Sprintf("app-%s", gofakeit.AppName()), + Metadata: &management.AddSAMLAppRequest_MetadataXml{MetadataXml: spMetadata}, + }) + if err != nil { + return nil, err + } + return resp, await(func() error { + _, err := i.Client.Mgmt.GetProjectByID(ctx, &management.GetProjectByIDRequest{ + Id: projectID, + }) + if err != nil { + return err + } + _, err = i.Client.Mgmt.GetAppByID(ctx, &management.GetAppByIDRequest{ + ProjectId: projectID, + AppId: resp.GetAppId(), + }) + return err + }) +} + +func (i *Instance) CreateSAMLAuthRequest(m *samlsp.Middleware, loginClient string, acs saml.Endpoint, relayState string, responseBinding string) (authRequestID string, err error) { + authReq, err := m.ServiceProvider.MakeAuthenticationRequest(acs.Location, acs.Binding, responseBinding) + if err != nil { + return "", err + } + + redirectURL, err := authReq.Redirect(relayState, &m.ServiceProvider) + if err != nil { + return "", err + } + + req, err := GetRequest(redirectURL.String(), map[string]string{oidc_internal.LoginClientHeader: loginClient}) + if err != nil { + return "", fmt.Errorf("get request: %w", err) + } + + loc, err := CheckRedirect(req) + if err != nil { + return "", fmt.Errorf("check redirect: %w", err) + } + + prefixWithHost := i.Issuer() + i.Config.LoginURLV2 + if !strings.HasPrefix(loc.String(), prefixWithHost) { + return "", fmt.Errorf("login location has not prefix %s, but is %s", prefixWithHost, loc.String()) + } + return strings.TrimPrefix(loc.String(), prefixWithHost), nil +} + +func (i *Instance) FailSAMLAuthRequest(ctx context.Context, id string, reason saml_pb.ErrorReason) *saml_pb.CreateResponseResponse { + resp, err := i.Client.SAMLv2.CreateResponse(ctx, &saml_pb.CreateResponseRequest{ + SamlRequestId: id, + ResponseKind: &saml_pb.CreateResponseRequest_Error{Error: &saml_pb.AuthorizationError{Error: reason}}, + }) + logging.OnError(err).Panic("create human user") + return resp +} + +func (i *Instance) SuccessfulSAMLAuthRequest(ctx context.Context, userId, id string) *saml_pb.CreateResponseResponse { + respSession, err := i.Client.SessionV2.CreateSession(ctx, &session_pb.CreateSessionRequest{ + Checks: &session_pb.Checks{ + User: &session_pb.CheckUser{ + Search: &session_pb.CheckUser_UserId{ + UserId: userId, + }, + }, + }, + }) + logging.OnError(err).Panic("create session") + + resp, err := i.Client.SAMLv2.CreateResponse(ctx, &saml_pb.CreateResponseRequest{ + SamlRequestId: id, + ResponseKind: &saml_pb.CreateResponseRequest_Session{ + Session: &saml_pb.Session{ + SessionId: respSession.GetSessionId(), + SessionToken: respSession.GetSessionToken(), + }, + }, + }) + logging.OnError(err).Panic("create human user") + return resp +} + +func (i *Instance) GetSAMLIDPMetadata() (*saml.EntityDescriptor, error) { + idpEntityID := http_util.BuildHTTP(i.Domain, i.Config.Port, i.Config.Secure) + "/saml/v2/metadata" + resp, err := http.Get(idpEntityID) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + entityDescriptor := new(saml.EntityDescriptor) + if err := xml.Unmarshal(data, entityDescriptor); err != nil { + return nil, err + } + + return entityDescriptor, nil +} + +func (i *Instance) Issuer() string { + return http_util.BuildHTTP(i.Domain, i.Config.Port, i.Config.Secure) +} diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index 30dd46a3c6..ebe7454b58 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -69,6 +69,7 @@ var ( DeviceAuthProjection *handler.Handler SessionProjection *handler.Handler AuthRequestProjection *handler.Handler + SamlRequestProjection *handler.Handler MilestoneProjection *handler.Handler QuotaProjection *quotaProjection LimitsProjection *handler.Handler @@ -157,6 +158,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, DeviceAuthProjection = newDeviceAuthProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["device_auth"])) SessionProjection = newSessionProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["sessions"])) AuthRequestProjection = newAuthRequestProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["auth_requests"])) + SamlRequestProjection = newSamlRequestProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["saml_requests"])) MilestoneProjection = newMilestoneProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["milestones"])) QuotaProjection = newQuotaProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["quotas"])) LimitsProjection = newLimitsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["limits"])) @@ -286,6 +288,7 @@ func newProjectionsList() { DeviceAuthProjection, SessionProjection, AuthRequestProjection, + SamlRequestProjection, MilestoneProjection, QuotaProjection.handler, LimitsProjection, diff --git a/internal/query/projection/saml_request.go b/internal/query/projection/saml_request.go new file mode 100644 index 0000000000..610619d31c --- /dev/null +++ b/internal/query/projection/saml_request.go @@ -0,0 +1,132 @@ +package projection + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" + old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/samlrequest" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + SamlRequestsProjectionTable = "projections.saml_requests" + + SamlRequestColumnID = "id" + SamlRequestColumnCreationDate = "creation_date" + SamlRequestColumnChangeDate = "change_date" + SamlRequestColumnSequence = "sequence" + SamlRequestColumnResourceOwner = "resource_owner" + SamlRequestColumnInstanceID = "instance_id" + SamlRequestColumnLoginClient = "login_client" + SamlRequestColumnIssuer = "issuer" + SamlRequestColumnACS = "acs" + SamlRequestColumnRelayState = "relay_state" + SamlRequestColumnBinding = "binding" +) + +type samlRequestProjection struct{} + +// Name implements handler.Projection. +func (*samlRequestProjection) Name() string { + return SamlRequestsProjectionTable +} + +func newSamlRequestProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, new(samlRequestProjection)) +} + +func (*samlRequestProjection) Init() *old_handler.Check { + return handler.NewMultiTableCheck( + handler.NewTable([]*handler.InitColumn{ + handler.NewColumn(SamlRequestColumnID, handler.ColumnTypeText), + handler.NewColumn(SamlRequestColumnCreationDate, handler.ColumnTypeTimestamp), + handler.NewColumn(SamlRequestColumnChangeDate, handler.ColumnTypeTimestamp), + handler.NewColumn(SamlRequestColumnSequence, handler.ColumnTypeInt64), + handler.NewColumn(SamlRequestColumnResourceOwner, handler.ColumnTypeText), + handler.NewColumn(SamlRequestColumnInstanceID, handler.ColumnTypeText), + handler.NewColumn(SamlRequestColumnLoginClient, handler.ColumnTypeText), + handler.NewColumn(SamlRequestColumnIssuer, handler.ColumnTypeText), + handler.NewColumn(SamlRequestColumnACS, handler.ColumnTypeText), + handler.NewColumn(SamlRequestColumnRelayState, handler.ColumnTypeText), + handler.NewColumn(SamlRequestColumnBinding, handler.ColumnTypeText), + }, + handler.NewPrimaryKey(SamlRequestColumnInstanceID, SamlRequestColumnID), + ), + ) +} + +func (p *samlRequestProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: samlrequest.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: samlrequest.AddedType, + Reduce: p.reduceSamlRequestAdded, + }, + { + Event: samlrequest.SucceededType, + Reduce: p.reduceSamlRequestEnded, + }, + { + Event: samlrequest.FailedType, + Reduce: p.reduceSamlRequestEnded, + }, + }, + }, + { + Aggregate: instance.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: instance.InstanceRemovedEventType, + Reduce: reduceInstanceRemovedHelper(SamlRequestColumnInstanceID), + }, + }, + }, + } +} + +func (p *samlRequestProjection) reduceSamlRequestAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*samlrequest.AddedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Sfwfa", "reduce.wrong.event.type %s", samlrequest.AddedType) + } + + return handler.NewCreateStatement( + e, + []handler.Column{ + handler.NewCol(SamlRequestColumnID, e.Aggregate().ID), + handler.NewCol(SamlRequestColumnInstanceID, e.Aggregate().InstanceID), + handler.NewCol(SamlRequestColumnCreationDate, e.CreationDate()), + handler.NewCol(SamlRequestColumnChangeDate, e.CreationDate()), + handler.NewCol(SamlRequestColumnResourceOwner, e.Aggregate().ResourceOwner), + handler.NewCol(SamlRequestColumnSequence, e.Sequence()), + handler.NewCol(SamlRequestColumnLoginClient, e.LoginClient), + handler.NewCol(SamlRequestColumnIssuer, e.Issuer), + handler.NewCol(SamlRequestColumnACS, e.ACSURL), + handler.NewCol(SamlRequestColumnRelayState, e.RelayState), + handler.NewCol(SamlRequestColumnBinding, e.Binding), + }, + ), nil +} + +func (p *samlRequestProjection) reduceSamlRequestEnded(event eventstore.Event) (*handler.Statement, error) { + switch event.(type) { + case *samlrequest.SucceededEvent, + *samlrequest.FailedEvent: + break + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-ASF3h", "reduce.wrong.event.type %s", []eventstore.EventType{samlrequest.SucceededType, samlrequest.FailedType}) + } + + return handler.NewDeleteStatement( + event, + []handler.Condition{ + handler.NewCond(SamlRequestColumnID, event.Aggregate().ID), + handler.NewCond(SamlRequestColumnInstanceID, event.Aggregate().InstanceID), + }, + ), nil +} diff --git a/internal/query/projection/saml_request_test.go b/internal/query/projection/saml_request_test.go new file mode 100644 index 0000000000..b0fe842d03 --- /dev/null +++ b/internal/query/projection/saml_request_test.go @@ -0,0 +1,123 @@ +package projection + +import ( + "testing" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/samlrequest" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestSamlRequestProjection_reduces(t *testing.T) { + type args struct { + event func(t *testing.T) eventstore.Event + } + tests := []struct { + name string + args args + reduce func(event eventstore.Event) (*handler.Statement, error) + want wantReduce + }{ + { + name: "reduceSamlRequestAdded", + args: args{ + event: getEvent(testEvent( + samlrequest.AddedType, + samlrequest.AggregateType, + []byte(`{"login_client": "loginClient", "issuer": "issuer", "acs_url": "acs", "relay_state": "relayState", "binding": "binding"}`), + ), eventstore.GenericEventMapper[samlrequest.AddedEvent]), + }, + reduce: (&samlRequestProjection{}).reduceSamlRequestAdded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("saml_request"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.saml_requests (id, instance_id, creation_date, change_date, resource_owner, sequence, login_client, issuer, acs, relay_state, binding) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + expectedArgs: []interface{}{ + "agg-id", + "instance-id", + anyArg{}, + anyArg{}, + "ro-id", + uint64(15), + "loginClient", + "issuer", + "acs", + "relayState", + "binding", + }, + }, + }, + }, + }, + }, + { + name: "reduceSamlRequestFailed", + args: args{ + event: getEvent(testEvent( + samlrequest.FailedType, + samlrequest.AggregateType, + []byte(`{"reason": 0}`), + ), eventstore.GenericEventMapper[samlrequest.FailedEvent]), + }, + reduce: (&samlRequestProjection{}).reduceSamlRequestEnded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("saml_request"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.saml_requests WHERE (id = $1) AND (instance_id = $2)", + expectedArgs: []interface{}{ + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceSamlRequestSucceeded", + args: args{ + event: getEvent(testEvent( + samlrequest.SucceededType, + samlrequest.AggregateType, + nil, + ), eventstore.GenericEventMapper[samlrequest.SucceededEvent]), + }, + reduce: (&samlRequestProjection{}).reduceSamlRequestEnded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("saml_request"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.saml_requests WHERE (id = $1) AND (instance_id = $2)", + expectedArgs: []interface{}{ + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := baseEvent(t) + got, err := tt.reduce(event) + if !zerrors.IsErrorInvalidArgument(err) { + t.Errorf("no wrong event mapping: %v, got: %v", err, got) + } + + event = tt.args.event(t) + got, err = tt.reduce(event) + assertReduce(t, got, err, SamlRequestsProjectionTable, tt.want) + }) + } +} diff --git a/internal/query/saml_request.go b/internal/query/saml_request.go new file mode 100644 index 0000000000..a0f6fdc6cd --- /dev/null +++ b/internal/query/saml_request.go @@ -0,0 +1,81 @@ +package query + +import ( + "context" + "database/sql" + _ "embed" + "errors" + "fmt" + "time" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/call" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type SamlRequest struct { + ID string + CreationDate time.Time + LoginClient string + Issuer string + ACS string + RelayState string + Binding string +} + +func (a *SamlRequest) checkLoginClient(ctx context.Context) error { + if uid := authz.GetCtxData(ctx).UserID; uid != a.LoginClient { + return zerrors.ThrowPermissionDenied(nil, "OIDCv2-aL0ag", "Errors.SamlRequest.WrongLoginClient") + } + return nil +} + +//go:embed saml_request_by_id.sql +var samlRequestByIDQuery string + +func (q *Queries) samlRequestByIDQuery(ctx context.Context) string { + return fmt.Sprintf(samlRequestByIDQuery, q.client.Timetravel(call.Took(ctx))) +} + +func (q *Queries) SamlRequestByID(ctx context.Context, shouldTriggerBulk bool, id string, checkLoginClient bool) (_ *SamlRequest, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if shouldTriggerBulk { + _, traceSpan := tracing.NewNamedSpan(ctx, "TriggerSamlRequestProjection") + ctx, err = projection.SamlRequestProjection.Trigger(ctx, handler.WithAwaitRunning()) + logging.OnError(err).Debug("trigger failed") + traceSpan.EndWithError(err) + } + + dst := new(SamlRequest) + err = q.client.QueryRowContext( + ctx, + func(row *sql.Row) error { + return row.Scan( + &dst.ID, &dst.CreationDate, &dst.LoginClient, &dst.Issuer, &dst.ACS, &dst.RelayState, &dst.Binding, + ) + }, + q.samlRequestByIDQuery(ctx), + id, authz.GetInstance(ctx).InstanceID(), + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, zerrors.ThrowNotFound(err, "QUERY-Thee9", "Errors.SamlRequest.NotExisting") + } + if err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-Ou8ue", "Errors.Internal") + } + + if checkLoginClient { + if err = dst.checkLoginClient(ctx); err != nil { + return nil, err + } + } + + return dst, nil +} diff --git a/internal/query/saml_request_by_id.sql b/internal/query/saml_request_by_id.sql new file mode 100644 index 0000000000..ac1c60058f --- /dev/null +++ b/internal/query/saml_request_by_id.sql @@ -0,0 +1,11 @@ +select + id, + creation_date, + login_client, + issuer, + acs, + relay_state, + binding +from projections.saml_requests %s +where id = $1 and instance_id = $2 +limit 1; diff --git a/internal/query/saml_request_test.go b/internal/query/saml_request_test.go new file mode 100644 index 0000000000..5cf58369cb --- /dev/null +++ b/internal/query/saml_request_test.go @@ -0,0 +1,127 @@ +package query + +import ( + "database/sql" + "database/sql/driver" + _ "embed" + "fmt" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestQueries_SamlRequestByID(t *testing.T) { + expQuery := regexp.QuoteMeta(fmt.Sprintf( + samlRequestByIDQuery, + asOfSystemTime, + )) + + cols := []string{ + projection.SamlRequestColumnID, + projection.SamlRequestColumnCreationDate, + projection.SamlRequestColumnLoginClient, + projection.SamlRequestColumnIssuer, + projection.SamlRequestColumnACS, + projection.SamlRequestColumnRelayState, + projection.SamlRequestColumnBinding, + } + type args struct { + shouldTriggerBulk bool + id string + checkLoginClient bool + } + tests := []struct { + name string + args args + expect sqlExpectation + want *SamlRequest + wantErr error + }{ + { + name: "success, all values", + args: args{ + shouldTriggerBulk: false, + id: "123", + checkLoginClient: true, + }, + expect: mockQuery(expQuery, cols, []driver.Value{ + "id", + testNow, + "loginClient", + "issuer", + "acs", + "relayState", + "binding", + }, "123", "instanceID"), + want: &SamlRequest{ + ID: "id", + CreationDate: testNow, + LoginClient: "loginClient", + Issuer: "issuer", + ACS: "acs", + RelayState: "relayState", + Binding: "binding", + }, + }, + { + name: "no rows", + args: args{ + shouldTriggerBulk: false, + id: "123", + }, + expect: mockQueryScanErr(expQuery, cols, nil, "123", "instanceID"), + wantErr: zerrors.ThrowNotFound(sql.ErrNoRows, "QUERY-Thee9", "Errors.SamlRequest.NotExisting"), + }, + { + name: "query error", + args: args{ + shouldTriggerBulk: false, + id: "123", + }, + expect: mockQueryErr(expQuery, sql.ErrConnDone, "123", "instanceID"), + wantErr: zerrors.ThrowInternal(sql.ErrConnDone, "QUERY-Ou8ue", "Errors.Internal"), + }, + { + name: "wrong login client", + args: args{ + shouldTriggerBulk: false, + id: "123", + checkLoginClient: true, + }, + expect: mockQuery(expQuery, cols, []driver.Value{ + "id", + testNow, + "wrongLoginClient", + "issuer", + "acs", + "relayState", + "binding", + }, "123", "instanceID"), + wantErr: zerrors.ThrowPermissionDeniedf(nil, "OIDCv2-aL0ag", "Errors.SamlRequest.WrongLoginClient"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + execMock(t, tt.expect, func(db *sql.DB) { + q := &Queries{ + client: &database.DB{ + DB: db, + Database: &prepareDB{}, + }, + } + ctx := authz.NewMockContext("instanceID", "orgID", "loginClient") + + got, err := q.SamlRequestByID(ctx, tt.args.shouldTriggerBulk, tt.args.id, tt.args.checkLoginClient) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + }) + } +} diff --git a/internal/repository/samlrequest/aggregate.go b/internal/repository/samlrequest/aggregate.go new file mode 100644 index 0000000000..551d64c70b --- /dev/null +++ b/internal/repository/samlrequest/aggregate.go @@ -0,0 +1,26 @@ +package samlrequest + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + AggregateType = "saml_request" + AggregateVersion = "v1" +) + +type Aggregate struct { + eventstore.Aggregate +} + +func NewAggregate(id, instanceID string) *Aggregate { + return &Aggregate{ + Aggregate: eventstore.Aggregate{ + Type: AggregateType, + Version: AggregateVersion, + ID: id, + ResourceOwner: instanceID, + InstanceID: instanceID, + }, + } +} diff --git a/internal/repository/samlrequest/eventstore.go b/internal/repository/samlrequest/eventstore.go new file mode 100644 index 0000000000..85cbec4460 --- /dev/null +++ b/internal/repository/samlrequest/eventstore.go @@ -0,0 +1,10 @@ +package samlrequest + +import "github.com/zitadel/zitadel/internal/eventstore" + +func init() { + eventstore.RegisterFilterEventMapper(AggregateType, AddedType, eventstore.GenericEventMapper[AddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SessionLinkedType, eventstore.GenericEventMapper[SessionLinkedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, FailedType, eventstore.GenericEventMapper[FailedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SucceededType, eventstore.GenericEventMapper[SucceededEvent]) +} diff --git a/internal/repository/samlrequest/saml_request.go b/internal/repository/samlrequest/saml_request.go new file mode 100644 index 0000000000..b3ecdd753e --- /dev/null +++ b/internal/repository/samlrequest/saml_request.go @@ -0,0 +1,172 @@ +package samlrequest + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + samlRequestEventPrefix = "saml_request." + AddedType = samlRequestEventPrefix + "added" + FailedType = samlRequestEventPrefix + "failed" + SessionLinkedType = samlRequestEventPrefix + "session.linked" + SucceededType = samlRequestEventPrefix + "succeeded" +) + +type AddedEvent struct { + *eventstore.BaseEvent `json:"-"` + + LoginClient string `json:"login_client,omitempty"` + ApplicationID string `json:"application_id,omitempty"` + ACSURL string `json:"acs_url,omitempty"` + RelayState string `json:"relay_state,omitempty"` + RequestID string `json:"request_id,omitempty"` + Binding string `json:"binding,omitempty"` + Issuer string `json:"issuer,omitempty"` + Destination string `json:"destination,omitempty"` +} + +func (e *AddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +func (e *AddedEvent) Payload() interface{} { + return e +} + +func (e *AddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewAddedEvent(ctx context.Context, + aggregate *eventstore.Aggregate, + loginClient, + applicationID string, + acsURL string, + relayState string, + requestID string, + binding string, + issuer string, + destination string, +) *AddedEvent { + return &AddedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + AddedType, + ), + LoginClient: loginClient, + ApplicationID: applicationID, + ACSURL: acsURL, + RelayState: relayState, + RequestID: requestID, + Binding: binding, + Issuer: issuer, + Destination: destination, + } +} + +type SessionLinkedEvent struct { + *eventstore.BaseEvent `json:"-"` + + SessionID string `json:"session_id"` + UserID string `json:"user_id"` + AuthTime time.Time `json:"auth_time"` + AuthMethods []domain.UserAuthMethodType `json:"auth_methods"` +} + +func (e *SessionLinkedEvent) Payload() interface{} { + return e +} + +func (e *SessionLinkedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewSessionLinkedEvent(ctx context.Context, + aggregate *eventstore.Aggregate, + sessionID, + userID string, + authTime time.Time, + authMethods []domain.UserAuthMethodType, +) *SessionLinkedEvent { + return &SessionLinkedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + SessionLinkedType, + ), + SessionID: sessionID, + UserID: userID, + AuthTime: authTime, + AuthMethods: authMethods, + } +} + +func (e *SessionLinkedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +type FailedEvent struct { + *eventstore.BaseEvent `json:"-"` + + Reason domain.SAMLErrorReason `json:"reason,omitempty"` +} + +func (e *FailedEvent) Payload() interface{} { + return e +} + +func (e *FailedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewFailedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + reason domain.SAMLErrorReason, +) *FailedEvent { + return &FailedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + FailedType, + ), + Reason: reason, + } +} + +func (e *FailedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} + +type SucceededEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *SucceededEvent) Payload() interface{} { + return nil +} + +func (e *SucceededEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewSucceededEvent(ctx context.Context, + aggregate *eventstore.Aggregate, +) *SucceededEvent { + return &SucceededEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + SucceededType, + ), + } +} + +func (e *SucceededEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = event +} diff --git a/internal/repository/samlsession/aggregate.go b/internal/repository/samlsession/aggregate.go new file mode 100644 index 0000000000..be702bc88a --- /dev/null +++ b/internal/repository/samlsession/aggregate.go @@ -0,0 +1,25 @@ +package samlsession + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + AggregateType = "saml_session" + 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/samlsession/eventstore.go b/internal/repository/samlsession/eventstore.go new file mode 100644 index 0000000000..36688e1814 --- /dev/null +++ b/internal/repository/samlsession/eventstore.go @@ -0,0 +1,12 @@ +package samlsession + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +func init() { + eventstore.RegisterFilterEventMapper(AggregateType, AddedType, eventstore.GenericEventMapper[AddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SAMLResponseAddedType, eventstore.GenericEventMapper[SAMLResponseAddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, SAMLResponseRevokedType, eventstore.GenericEventMapper[SAMLResponseRevokedEvent]) + +} diff --git a/internal/repository/samlsession/saml_session.go b/internal/repository/samlsession/saml_session.go new file mode 100644 index 0000000000..d79ebdfd00 --- /dev/null +++ b/internal/repository/samlsession/saml_session.go @@ -0,0 +1,139 @@ +package samlsession + +import ( + "context" + "time" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + samlSessionEventPrefix = "saml_session." + AddedType = samlSessionEventPrefix + "added" + SAMLResponseAddedType = samlSessionEventPrefix + "saml_response.added" + SAMLResponseRevokedType = samlSessionEventPrefix + "saml_response.revoked" +) + +type AddedEvent struct { + eventstore.BaseEvent `json:"-"` + + UserID string `json:"userID"` + UserResourceOwner string `json:"userResourceOwner"` + SessionID string `json:"sessionID"` + EntityID string `json:"entityID"` + Audience []string `json:"audience"` + AuthMethods []domain.UserAuthMethodType `json:"authMethods"` + AuthTime time.Time `json:"authTime"` + PreferredLanguage *language.Tag `json:"preferredLanguage,omitempty"` + UserAgent *domain.UserAgent `json:"userAgent,omitempty"` +} + +func (e *AddedEvent) Payload() interface{} { + return e +} + +func (e *AddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *AddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewAddedEvent(ctx context.Context, + aggregate *eventstore.Aggregate, + userID, + userResourceOwner, + sessionID, + entityID string, + audience []string, + authMethods []domain.UserAuthMethodType, + authTime time.Time, + preferredLanguage *language.Tag, + userAgent *domain.UserAgent, +) *AddedEvent { + return &AddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + AddedType, + ), + UserID: userID, + UserResourceOwner: userResourceOwner, + SessionID: sessionID, + EntityID: entityID, + Audience: audience, + AuthMethods: authMethods, + AuthTime: authTime, + PreferredLanguage: preferredLanguage, + UserAgent: userAgent, + } +} + +type SAMLResponseAddedEvent struct { + eventstore.BaseEvent `json:"-"` + + ID string `json:"id,omitempty"` + Lifetime time.Duration `json:"lifetime,omitempty"` +} + +func (e *SAMLResponseAddedEvent) Payload() interface{} { + return e +} + +func (e *SAMLResponseAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *SAMLResponseAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewSAMLResponseAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id string, + lifetime time.Duration, +) *SAMLResponseAddedEvent { + return &SAMLResponseAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + SAMLResponseAddedType, + ), + ID: id, + Lifetime: lifetime, + } +} + +type SAMLResponseRevokedEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *SAMLResponseRevokedEvent) Payload() interface{} { + return e +} + +func (e *SAMLResponseRevokedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *SAMLResponseRevokedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewSAMLResponseRevokedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *SAMLResponseRevokedEvent { + return &SAMLResponseRevokedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + SAMLResponseRevokedType, + ), + } +} diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 01582d0a15..5539fabb12 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -566,6 +566,13 @@ Errors: Token: Invalid: Токенът е невалиден Expired: Токенът е изтекъл + InvalidClient: Токенът не е издаден за този клиент + SAMLRequest: + AlreadyExists: SAMLRequest вече съществува + NotExisting: SAMLRequest не съществува + WrongLoginClient: SAMLRequest, създаден от друг клиент за влизане + SAMLSession: + InvalidClient: SAMLResponse не е издаден за този клиент Feature: NotExisting: Функцията не съществува TypeNotSupported: Типът функция не се поддържа @@ -640,6 +647,8 @@ AggregateTypes: system: Система session: Сесия web_key: Уеб ключ + saml_request: SAML заявка + saml_session: SAML сесия EventTypes: execution: diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index effff55828..6a21286a2c 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -547,6 +547,12 @@ Errors: Invalid: Token je neplatný Expired: Token vypršel InvalidClient: Token nebyl vydán pro tohoto klienta + SAMLRequest: + AlreadyExists: SAMLRequest již existuje + NotExisting: SAMLRequest neexistuje + WrongLoginClient: SAMLRequest vytvořený jiným přihlašovacím klientem + SAMLSession: + InvalidClient: Pro tohoto klienta nebyla vydána odpověď SAMLResponse Feature: NotExisting: Funkce neexistuje TypeNotSupported: Typ funkce není podporován @@ -621,6 +627,8 @@ AggregateTypes: system: Systém session: Sezení web_key: Webový klíč + saml_request: Žádost SAML + saml_session: Relace SAML EventTypes: execution: diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 21a863d18c..117bbcb897 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -549,6 +549,12 @@ Errors: Invalid: Token ist ungültig Expired: Token ist abgelaufen InvalidClient: Token wurde nicht für diesen Client ausgestellt + SAMLRequest: + AlreadyExists: SAMLRequest existiert bereits + NotExisting: SAMLRequest existiert nicht + WrongLoginClient: SAMLRequest wurde con einem andere Login-Client erstellt + SAMLSession: + InvalidClient: SAMLResponse wurde nicht für diesen Client ausgestellt Feature: NotExisting: Feature existiert nicht TypeNotSupported: Feature Typ wird nicht unterstützt @@ -623,6 +629,8 @@ AggregateTypes: system: System session: Session web_key: Webschlüssel + saml_request: SAML Request + saml_session: SAML Session EventTypes: execution: diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 6ba27b8280..618f4a500a 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -550,6 +550,12 @@ Errors: Invalid: Token is invalid Expired: Token is expired InvalidClient: Token was not issued for this client + SAMLRequest: + AlreadyExists: SAMLRequest already exists + NotExisting: SAMLRequest does not exist + WrongLoginClient: SAMLRequest created by other login client + SAMLSession: + InvalidClient: SAMLResponse was not issued for this client Feature: NotExisting: Feature does not exist TypeNotSupported: Feature type is not supported @@ -624,6 +630,8 @@ AggregateTypes: system: System session: Session web_key: Web Key + saml_request: SAML Request + saml_session: SAML Session EventTypes: execution: diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 8afb690833..b2c0c0a685 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -549,6 +549,12 @@ Errors: Invalid: El token no es válido Expired: El token ha caducado InvalidClient: El token no ha sido emitido para este cliente + SAMLRequest: + AlreadyExists: SAMLRequest ya existe + NotExisting: SAMLRequest no existe + WrongLoginClient: SAMLRequest creado por otro cliente de inicio de sesión + SAMLSession: + InvalidClient: SAMLResponse no ha sido emitido para este cliente Feature: NotExisting: La característica no existe TypeNotSupported: El tipo de característica no es compatible @@ -623,6 +629,8 @@ AggregateTypes: system: Sistema session: Sesión web_key: Clave web + saml_request: Solicitud SAML + saml_session: Sesión SAML EventTypes: execution: diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 7f92dbea4a..eb143e592c 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -549,6 +549,12 @@ Errors: Invalid: Le jeton n'est pas valide Expired: Le jeton est expiré InvalidClient: Le token n'a pas été émis pour ce client + SAMLRequest: + AlreadyExists: SAMLRequest existe déjà + NotExisting: SAMLRequest n'existe pas + WrongLoginClient: SAMLRequest créé par un autre client de connexion + SAMLSession: + InvalidClient: SAMLResponse n'a pas été émise pour ce client Feature: NotExisting: La fonctionnalité n'existe pas TypeNotSupported: Le type de fonctionnalité n'est pas pris en charge @@ -623,6 +629,8 @@ AggregateTypes: system: Système session: Session web_key: Clé Web + saml_request: Requête SAML + saml_session: Session SAML EventTypes: execution: diff --git a/internal/static/i18n/hu.yaml b/internal/static/i18n/hu.yaml index c8e2cabb4e..d33b5f47bc 100644 --- a/internal/static/i18n/hu.yaml +++ b/internal/static/i18n/hu.yaml @@ -549,6 +549,12 @@ Errors: Invalid: A Token érvénytelen Expired: A Token lejárt InvalidClient: A Token nem ehhez a klienshez lett kiadva + SAMLRequest: + AlreadyExists: A SAMLRequest már létezik + NotExisting: A SAMLRequest nem létezik + WrongLoginClient: A SAMLRequest egy másik bejelentkezési ügyfél által létrehozott + SAMLSession: + InvalidClient: SAMLResponse nem lett kiadva ehhez az ügyfélhez Feature: NotExisting: A funkció nem létezik TypeNotSupported: A funkció típusa nem támogatott @@ -599,6 +605,7 @@ Errors: FeatureDisabled: A webkulcs funkció le van tiltva NoActive: Aktív web kulcs nem található NotFound: Web kulcs nem található + AggregateTypes: action: Művelet instance: Példány @@ -622,6 +629,9 @@ AggregateTypes: system: Rendszer session: Munkamenet web_key: Webkulcs + saml_request: SAML-kérés + saml_session: SAML munkamenet + EventTypes: execution: set: Végrehajtási készlet diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index 60e4c395ad..449f91ffdc 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -549,6 +549,12 @@ Errors: Invalid: Token tidak valid Expired: Token sudah habis masa berlakunya InvalidClient: Token tidak dikeluarkan untuk klien ini + SAMLRequest: + AlreadyExists: SAMLRequest sudah ada + NotExisting: SAMLRequest tidak ada + WrongLoginClient: SAMLRequest dibuat oleh klien login lainnya + SAMLSession: + InvalidClient: SAMLResponse tidak dikeluarkan untuk klien ini Feature: NotExisting: Fitur tidak ada TypeNotSupported: Jenis fitur tidak didukung @@ -594,6 +600,7 @@ Errors: FeatureDisabled: Fitur kunci web dinonaktifkan NoActive: Tidak ditemukan kunci web aktif NotFound: Kunci web tidak ditemukan + AggregateTypes: action: Tindakan instance: Contoh @@ -617,6 +624,9 @@ AggregateTypes: system: Sistem session: Sidang web_key: Kunci Web + saml_request: Sesi SAML + saml_session: Permintaan SAML + EventTypes: execution: set: Kumpulan eksekusi diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 0d7d39d2db..a94925a906 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -549,6 +549,12 @@ Errors: Invalid: Token non è valido Expired: Token è scaduto InvalidClient: Il token non è stato emesso per questo cliente + SAMLRequest: + AlreadyExists: SAMLRequest esiste già + NotExisting: SAMLRequest non esiste + WrongLoginClient: SAMLRequest creato da un altro client di accesso + SAMLSession: + InvalidClient: SAMLResponse non è stato emesso per questo client Feature: NotExisting: La funzionalità non esiste TypeNotSupported: Il tipo di funzionalità non è supportato @@ -623,6 +629,8 @@ AggregateTypes: system: Sistema session: Sessione web_key: Chiave Web + saml_request: Richiesta SAML + saml_session: Sessione SAML EventTypes: execution: diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index f340b490ba..582e5037bb 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -538,6 +538,12 @@ Errors: Invalid: トークンが無効です Expired: トークンの有効期限が切れている InvalidClient: トークンが発行されていません + SAMLRequest: + AlreadyExists: SAMLリクエストはすでに存在します + NotExisting: SAMLリクエストが存在しません + WrongLoginClient: 他のログイン クライアントによって作成された SAMLRequest + SAMLSession: + InvalidClient: このクライアントに対してSAMLResponseは発行されませんでした Feature: NotExisting: 機能が存在しません TypeNotSupported: 機能タイプはサポートされていません @@ -612,6 +618,8 @@ AggregateTypes: system: システム session: セッション web_key: Web キー + saml_request: SAML リクエスト + saml_session: SAMLセッション EventTypes: execution: diff --git a/internal/static/i18n/ko.yaml b/internal/static/i18n/ko.yaml index fda5171de2..741f075ca2 100644 --- a/internal/static/i18n/ko.yaml +++ b/internal/static/i18n/ko.yaml @@ -550,6 +550,12 @@ Errors: Invalid: 토큰이 유효하지 않습니다 Expired: 토큰이 만료되었습니다 InvalidClient: 토큰이 이 클라이언트에 대해 발행되지 않았습니다 + SAMLRequest: + AlreadyExists: SAMLRequest가 이미 존재합니다 + NotExisting: SAMLRequest가 존재하지 않습니다 + WrongLoginClient: 다른 로그인 클라이언트가 생성한 SAMLRequest + SAMLSession: + InvalidClient: 이 클라이언트에 대해 SAMLResponse가 발행되지 않았습니다. Feature: NotExisting: 기능이 존재하지 않습니다 TypeNotSupported: 기능 유형이 지원되지 않습니다 @@ -624,6 +630,8 @@ AggregateTypes: system: 시스템 session: 세션 web_key: 웹 키 + saml_request: SAML 요청 + saml_session: SAML 세션 EventTypes: execution: diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index c23efe3152..be205f5380 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -548,6 +548,12 @@ Errors: Invalid: токенот е неважечки Expired: токенот е истечен InvalidClient: Токен не беше издаден на овој клиент + SAMLRequest: + AlreadyExists: SAMLRequest веќе постои + NotExisting: SAMLRequest не постои + WrongLoginClient: SAML Барање создадено од друг клиент за најавување + SAMLSession: + InvalidClient: SAMLResponse не беше издаден за овој клиент Feature: NotExisting: Функцијата не постои TypeNotSupported: Типот на функција не е поддржан @@ -622,6 +628,8 @@ AggregateTypes: system: Систем session: Сесија web_key: Веб клуч + saml_request: Барање SAML + saml_session: SAML сесија EventTypes: execution: diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index 89477091d6..262b24f2fb 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -549,6 +549,12 @@ Errors: Invalid: Token is ongeldig Expired: Token is verlopen InvalidClient: Token is niet uitgegeven voor deze client + SAMLRequest: + AlreadyExists: SAMLRequest bestaat al + NotExisting: SAMLRequest bestaat niet + WrongLoginClient: SAMLRequest aangemaakt door andere login client + SAMLSession: + InvalidClient: SAMLResponse is niet uitgegeven voor deze client Feature: NotExisting: Functie bestaat niet TypeNotSupported: Functie type wordt niet ondersteund @@ -623,6 +629,8 @@ AggregateTypes: system: Systeem session: Sessie web_key: Websleutel + saml_request: SAML-aanvraag + saml_session: SAML-sessie EventTypes: execution: diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index ddd6df8be7..cc511ec0c0 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -549,6 +549,12 @@ Errors: Invalid: Token jest nieprawidłowy Expired: Token wygasł InvalidClient: Token nie został wydany dla tego klienta + SAMLRequest: + AlreadyExists: SAMLRequest już istnieje + NotExisting: SAMLRequest nie istnieje + WrongLoginClient: SAMLRequest utworzony przez innego klienta logowania + SAMLSession: + InvalidClient: SAMLResponse nie został wydany dla tego klienta Feature: NotExisting: Funkcja nie istnieje TypeNotSupported: Typ funkcji nie jest obsługiwany @@ -623,6 +629,8 @@ AggregateTypes: system: System session: Sesja web_key: Klucz internetowy + saml_request: Żądanie SAML + saml_session: Sesja SAML EventTypes: execution: diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 88e798ca0b..bd106ab259 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -544,6 +544,16 @@ Errors: WrongLoginClient: A solicitação de autenticação foi criada por outro cliente de login OIDCSession: RefreshTokenInvalid: O Refresh Token é inválido + Token: + Invalid: O token é inválido + Expired: O token expirou + InvalidClient: O token não foi emitido para este cliente + SAMLRequest: + AlreadyExists: O SAMLRequest já existe + NotExisting: O SAMLRequest não existe + WrongLoginClient: SAMLRequest criado por outro cliente de login + SAMLSession: + InvalidClient: O SAMLResponse não foi emitido para este cliente Feature: NotExisting: O recurso não existe TypeNotSupported: O tipo de recurso não é compatível @@ -618,6 +628,8 @@ AggregateTypes: system: Sistema session: Sessão web_key: Chave da Web + saml_request: Solicitação SAML + saml_session: Sessão SAML EventTypes: execution: diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index e253b5a678..8c4b079f2e 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -538,6 +538,12 @@ Errors: Invalid: Токен недействителен Expired: Срок действия токена истек InvalidClient: Токен не был выпущен для этого клиента + SAMLRequest: + AlreadyExists: SAMLRequest уже существует + NotExisting: SAMLRequest не существует + WrongLoginClient: SAMLRequest создан другим клиентом входа + SAMLSession: + InvalidClient: SAMLResponse не был отправлен для этого клиента Feature: NotExisting: ункция не существует TypeNotSupported: Тип объекта не поддерживается @@ -612,6 +618,8 @@ AggregateTypes: system: Система session: Сеанс web_key: Веб-ключ + saml_request: SAML-запрос + saml_session: Сессия SAML EventTypes: execution: diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index 91198335b8..e31095b78c 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -549,6 +549,12 @@ Errors: Invalid: Token är ogiltig Expired: Token har gått ut InvalidClient: Token utfärdades inte för denna klient + SAMLRequest: + AlreadyExists: SAMLRequest finns redan + NotExisting: SAMLRequest finns inte + WrongLoginClient: SAMLRequest skapad av annan inloggningsklient + SAMLSession: + InvalidClient: SAMLResponse utfärdades inte för den här klienten Feature: NotExisting: Funktionen existerar inte TypeNotSupported: Funktionstypen stöds inte @@ -623,6 +629,8 @@ AggregateTypes: system: System session: Session web_key: Webbnyckel + saml_request: SAML-förfrågan + saml_session: SAML-session EventTypes: execution: diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 8e94bc2623..b30609da90 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -549,6 +549,12 @@ Errors: Invalid: 令牌无效 Expired: 令牌已过期 InvalidClient: 没有为该客户发放令牌 + SAMLRequest: + AlreadyExists: SAMLRequest 已存在 + NotExisting: SAMLRequest不存在 + WrongLoginClient: 其他登录客户端创建的 SAMLRequest + SAMLSession: + InvalidClient: 未向该客户端发出 SAMLResponse Feature: NotExisting: 功能不存在 TypeNotSupported: 不支持功能类型 @@ -623,6 +629,8 @@ AggregateTypes: system: 系统 session: 会话 web_key: Web 密钥 + saml_request: SAML 请求 + saml_session: SAML 会话 EventTypes: execution: diff --git a/pkg/grpc/saml/v2/saml.go b/pkg/grpc/saml/v2/saml.go new file mode 100644 index 0000000000..0f96597339 --- /dev/null +++ b/pkg/grpc/saml/v2/saml.go @@ -0,0 +1,3 @@ +package saml + +type Redirect = isCreateResponseRequest_ResponseKind diff --git a/proto/zitadel/oidc/v2/oidc_service.proto b/proto/zitadel/oidc/v2/oidc_service.proto index 85044e9570..3c36057afa 100644 --- a/proto/zitadel/oidc/v2/oidc_service.proto +++ b/proto/zitadel/oidc/v2/oidc_service.proto @@ -169,8 +169,8 @@ message CreateCallbackRequest { string auth_request_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "Set this field when the authorization flow failed. It creates a callback URL to the application, with the error details set."; - ref: "https://openid.net/specs/openid-connect-core-1_0.html#AuthError"; + description: "ID of the Auth Request."; + example: "\"163840776835432705\""; } ]; diff --git a/proto/zitadel/saml/v2/authorization.proto b/proto/zitadel/saml/v2/authorization.proto new file mode 100644 index 0000000000..c93f8d7b98 --- /dev/null +++ b/proto/zitadel/saml/v2/authorization.proto @@ -0,0 +1,71 @@ +syntax = "proto3"; + +package zitadel.saml.v2; + +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/saml/v2;saml"; + +message SAMLRequest{ + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + external_docs: { + url: "https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html"; + description: "Find out more about SAMLRequest parameters"; + } + }; + + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "ID of the SAMLRequest"; + } + ]; + + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Time when the SAMLRequest was created"; + } + ]; + + string issuer = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "SAML entityID of the application that created the SAMLRequest"; + } + ]; + + string assertion_consumer_service = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "URL which points back to the assertion consumer service of the application"; + } + ]; + + string relay_state = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "RelayState provided by the application for the request"; + } + ]; + + string binding = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Binding used by the application for the request"; + } + ]; +} + +message AuthorizationError { + ErrorReason error = 1; + optional string error_description = 2; +} + +enum ErrorReason { + ERROR_REASON_UNSPECIFIED = 0; + + ERROR_REASON_VERSION_MISSMATCH = 1; + ERROR_REASON_AUTH_N_FAILED = 2; + ERROR_REASON_INVALID_ATTR_NAME_OR_VALUE = 3; + ERROR_REASON_INVALID_NAMEID_POLICY = 4; + ERROR_REASON_REQUEST_DENIED =5; + ERROR_REASON_REQUEST_UNSUPPORTED = 6; + ERROR_REASON_UNSUPPORTED_BINDING = 7; +} \ No newline at end of file diff --git a/proto/zitadel/saml/v2/saml_service.proto b/proto/zitadel/saml/v2/saml_service.proto new file mode 100644 index 0000000000..3198cf3086 --- /dev/null +++ b/proto/zitadel/saml/v2/saml_service.proto @@ -0,0 +1,227 @@ +syntax = "proto3"; + +package zitadel.saml.v2; + +import "zitadel/object/v2/object.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "zitadel/saml/v2/authorization.proto"; +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/saml/v2;saml"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "SAML Service"; + version: "2.0"; + description: "Get SAML Auth Request details and create callback URLs."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSE"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +service SAMLService { + rpc GetSAMLRequest (GetSAMLRequestRequest) returns (GetSAMLRequestResponse) { + option (google.api.http) = { + get: "/v2/saml/saml_requests/{saml_request_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get SAML Request details"; + description: "Get SAML Request details by ID. Returns details that are parsed from the application's SAML Request." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc CreateResponse (CreateResponseRequest) returns (CreateResponseResponse) { + option (google.api.http) = { + post: "/v2/saml/saml_requests/{saml_request_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Finalize a SAML Request and get the response."; + description: "Finalize a SAML Request and get the response definition for success or failure. The response must be handled as per the SAML definition to inform the application about the success or failure. On success, the response contains details for the application to obtain the SAMLResponse. This method can only be called once for an SAML request." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } +} + +message GetSAMLRequestRequest { + // ID of the SAML Request, as obtained from the redirect URL. + string saml_request_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432705\""; + } + ]; +} + +message GetSAMLRequestResponse { + SAMLRequest saml_request = 1; +} + +message CreateResponseRequest { + // ID of the SAML Request. + string saml_request_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"163840776835432705\""; + } + ]; + + oneof response_kind { + option (validate.required) = true; + Session session = 2; + // Set this field when the authorization flow failed. It creates a response depending on the SP, with the error details set. + AuthorizationError error = 3; + } +} + +message Session { + // ID of the session, used to login the user. Connects the session to the SAML Request. + string session_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432705\""; + } + ]; + + // Token to verify the session is valid. + string session_token = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + } + ]; +} + +message CreateResponseResponse { + zitadel.object.v2.Details details = 1; + // URL including the Assertion Consumer Service where the user should be redirected or has to call per POST, depending on the binding. Contains details for the application to obtain the response on success, or error details on failure. Note that this field must be treated as credentials, as the contained SAMLResponse or code can be used on behalve of the user. + string url = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"https://client.example.org/cb\"" + } + ]; + // Binding is defined through the request, what the IDP is able to use and what bindings are available for the SP. + oneof binding { + // Set if the binding is Redirect-Binding, where the user can directly be redirected to the application, using a \"302 FOUND\" status to the URL. + RedirectResponse redirect = 3; + // Set if the binding is POST-Binding, where the application expects to be called per HTTP POST with the SAMLResponse and RelayState in the form body. + PostResponse post = 4; + } +} + +message RedirectResponse{} +message PostResponse{ + string relay_state = 1; + string saml_response = 2; +} \ No newline at end of file