From 2b6647e41df9a8b2341083bc25bb76117d7d80ae Mon Sep 17 00:00:00 2001 From: Stefan Jacobi Date: Tue, 30 Jan 2024 09:51:59 +0100 Subject: [PATCH 1/3] fix(saml): add error logging to handler * add missing error logging as there is no real error output when there is a internal server error * switch parseSamlResponse errors to invalid request errors. Fixes: #1254 --- backend/ee/saml/handler.go | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/backend/ee/saml/handler.go b/backend/ee/saml/handler.go index 692277dab..859bfa41b 100644 --- a/backend/ee/saml/handler.go +++ b/backend/ee/saml/handler.go @@ -80,17 +80,20 @@ func (handler *SamlHandler) Metadata(c echo.Context) error { var request dto.SamlMetadataRequest err := c.Bind(&request) if err != nil { + c.Logger().Error(err) return c.JSON(http.StatusBadRequest, thirdparty.ErrorInvalidRequest("domain is missing")) } foundProvider, err := handler.getProviderByDomain(request.Domain) if err != nil { + c.Logger().Error(err) return c.NoContent(http.StatusNotFound) } if request.CertOnly { cert, err := handler.persister.GetSamlCertificatePersister().GetFirst() if err != nil { + c.Logger().Error(err) return c.JSON(http.StatusInternalServerError, thirdparty.ErrorServer("unable to provide metadata").WithCause(err)) } @@ -104,6 +107,7 @@ func (handler *SamlHandler) Metadata(c echo.Context) error { xmlMetadata, err := foundProvider.ProvideMetadataAsXml() if err != nil { + c.Logger().Error(err) return c.JSON(http.StatusInternalServerError, thirdparty.ErrorServer("unable to provide metadata").WithCause(err)) } @@ -120,11 +124,13 @@ func (handler *SamlHandler) Auth(c echo.Context) error { var request dto.SamlAuthRequest err := c.Bind(&request) if err != nil { + c.Logger().Error(err) return handler.redirectError(c, thirdparty.ErrorInvalidRequest(err.Error()).WithCause(err), errorRedirectTo) } err = c.Validate(request) if err != nil { + c.Logger().Error(err) return handler.redirectError(c, thirdparty.ErrorInvalidRequest(err.Error()).WithCause(err), errorRedirectTo) } @@ -134,6 +140,7 @@ func (handler *SamlHandler) Auth(c echo.Context) error { foundProvider, err := handler.getProviderByDomain(request.Domain) if err != nil { + c.Logger().Error(err) return handler.redirectError(c, thirdparty.ErrorInvalidRequest(err.Error()).WithCause(err), errorRedirectTo) } @@ -144,11 +151,13 @@ func (handler *SamlHandler) Auth(c echo.Context) error { request.RedirectTo) if err != nil { + c.Logger().Error(err) return handler.redirectError(c, thirdparty.ErrorServer("could not generate state").WithCause(err), errorRedirectTo) } redirectUrl, err := foundProvider.GetService().BuildAuthURL(string(state)) if err != nil { + c.Logger().Error(err) return handler.redirectError(c, thirdparty.ErrorServer("could not generate auth url").WithCause(err), errorRedirectTo) } @@ -158,6 +167,7 @@ func (handler *SamlHandler) Auth(c echo.Context) error { func (handler *SamlHandler) CallbackPost(c echo.Context) error { state, samlError := VerifyState(handler.config, handler.persister.GetSamlStatePersister(), c.FormValue("RelayState")) if samlError != nil { + c.Logger().Error(samlError) return handler.redirectError( c, thirdparty.ErrorInvalidRequest(samlError.Error()).WithCause(samlError), @@ -171,6 +181,7 @@ func (handler *SamlHandler) CallbackPost(c echo.Context) error { redirectTo, samlError := url.Parse(state.RedirectTo) if samlError != nil { + c.Logger().Error(samlError) return handler.redirectError( c, thirdparty.ErrorServer("unable to parse redirect url").WithCause(samlError), @@ -180,6 +191,7 @@ func (handler *SamlHandler) CallbackPost(c echo.Context) error { foundProvider, samlError := handler.getProviderByDomain(state.Provider) if samlError != nil { + c.Logger().Error(samlError) return handler.redirectError( c, thirdparty.ErrorServer("unable to find provider by domain").WithCause(samlError), @@ -189,6 +201,7 @@ func (handler *SamlHandler) CallbackPost(c echo.Context) error { assertionInfo, samlError := handler.parseSamlResponse(foundProvider, c.FormValue("SAMLResponse")) if samlError != nil { + c.Logger().Error(samlError) return handler.redirectError( c, thirdparty.ErrorServer("unable to parse saml response").WithCause(samlError), @@ -198,6 +211,7 @@ func (handler *SamlHandler) CallbackPost(c echo.Context) error { redirectUrl, samlError := handler.linkAccount(c, redirectTo, state, foundProvider, assertionInfo) if samlError != nil { + c.Logger().Error(samlError) return handler.redirectError( c, samlError, @@ -270,15 +284,15 @@ func (handler *SamlHandler) createHankoToken(linkResult *thirdparty.AccountLinki func (handler *SamlHandler) parseSamlResponse(provider provider.ServiceProvider, samlResponse string) (*saml2.AssertionInfo, error) { assertionInfo, err := provider.GetService().RetrieveAssertionInfo(samlResponse) if err != nil { - return nil, thirdparty.ErrorServer("unable to parse SAML response").WithCause(err) + return nil, thirdparty.ErrorInvalidRequest("unable to parse SAML response").WithCause(err) } if assertionInfo.WarningInfo.InvalidTime { - return nil, thirdparty.ErrorServer("SAMLAssertion expired") + return nil, thirdparty.ErrorInvalidRequest("SAMLAssertion expired") } if assertionInfo.WarningInfo.NotInAudience { - return nil, thirdparty.ErrorServer("not in SAML audience") + return nil, thirdparty.ErrorInvalidRequest("not in SAML audience") } return assertionInfo, nil @@ -309,11 +323,13 @@ func (handler *SamlHandler) GetProvider(c echo.Context) error { var request dto.SamlRequest err := c.Bind(&request) if err != nil { + c.Logger().Error(err) return c.JSON(http.StatusBadRequest, err) } foundProvider, err := handler.getProviderByDomain(request.Domain) if err != nil { + c.Logger().Error(err) return c.NoContent(http.StatusNotFound) } From b26ab80ea02245fcf34d5af45f39707a607298ea Mon Sep 17 00:00:00 2001 From: Stefan Jacobi Date: Thu, 1 Feb 2024 15:53:48 +0100 Subject: [PATCH 2/3] feat(saml): add saml config to database * add migrations * add database models * create a list of config providers and db providers on every call to be able to dynamically update them in case a new one was saved to the database -> prevents restarting the application Closes: #1295 --- backend/dto/config.go | 13 +- backend/ee/saml/admin/handler.go | 224 ++++++++ backend/ee/saml/admin/handler_test.go | 532 ++++++++++++++++++ backend/ee/saml/config/saml.go | 4 - backend/ee/saml/dto/saml.go | 140 +++++ backend/ee/saml/handler.go | 145 ++++- backend/ee/saml/provider/auth0.go | 17 +- backend/ee/saml/provider/provider.go | 2 +- backend/ee/saml/provider/provider_test.go | 358 ++++++++++++ backend/ee/saml/provider/saml.go | 7 +- backend/ee/saml/provider/saml_test.go | 397 +++++++++++++ backend/ee/saml/router.go | 14 + backend/ee/saml/utils/url_test.go | 69 +++ backend/handler/admin_router.go | 3 + ...2_create_saml_identity_providers.down.fizz | 1 + ...832_create_saml_identity_providers.up.fizz | 10 + ...20923_create_saml_attribute_maps.down.fizz | 1 + ...0120923_create_saml_attribute_maps.up.fizz | 26 + .../persistence/models/saml_attribute_map.go | 45 ++ .../models/saml_identity_provider.go | 39 ++ backend/persistence/persister.go | 8 + .../saml_identity_provider_persister.go | 128 +++++ .../fixtures/saml/saml_attribute_maps.yaml | 22 + .../test/fixtures/saml/saml_certificates.yaml | 75 +++ .../saml/saml_identity_providers.yaml | 16 + backend/test/persister.go | 4 + 26 files changed, 2252 insertions(+), 48 deletions(-) create mode 100644 backend/ee/saml/admin/handler.go create mode 100644 backend/ee/saml/admin/handler_test.go create mode 100644 backend/ee/saml/provider/provider_test.go create mode 100644 backend/ee/saml/provider/saml_test.go create mode 100644 backend/ee/saml/utils/url_test.go create mode 100644 backend/persistence/migrations/20240130120832_create_saml_identity_providers.down.fizz create mode 100644 backend/persistence/migrations/20240130120832_create_saml_identity_providers.up.fizz create mode 100644 backend/persistence/migrations/20240130120923_create_saml_attribute_maps.down.fizz create mode 100644 backend/persistence/migrations/20240130120923_create_saml_attribute_maps.up.fizz create mode 100644 backend/persistence/models/saml_attribute_map.go create mode 100644 backend/persistence/models/saml_identity_provider.go create mode 100644 backend/persistence/saml_identity_provider_persister.go create mode 100644 backend/test/fixtures/saml/saml_attribute_maps.yaml create mode 100644 backend/test/fixtures/saml/saml_certificates.yaml create mode 100644 backend/test/fixtures/saml/saml_identity_providers.yaml diff --git a/backend/dto/config.go b/backend/dto/config.go index a1efe0497..5ff0993f7 100644 --- a/backend/dto/config.go +++ b/backend/dto/config.go @@ -39,16 +39,5 @@ func GetEnabledProviders(providers config.ThirdPartyProviders) []string { } func UseEnterpriseConnection(samlConfig *samlConfig.Saml) bool { - hasProvider := false - - if samlConfig != nil && samlConfig.Enabled { - for _, availableProvider := range samlConfig.IdentityProviders { - if availableProvider.Enabled { - hasProvider = true - } - } - } - - return hasProvider - + return samlConfig != nil && samlConfig.Enabled } diff --git a/backend/ee/saml/admin/handler.go b/backend/ee/saml/admin/handler.go new file mode 100644 index 000000000..e4c0488c8 --- /dev/null +++ b/backend/ee/saml/admin/handler.go @@ -0,0 +1,224 @@ +package admin + +import ( + "fmt" + "github.com/gobuffalo/pop/v6" + "github.com/gofrs/uuid" + "github.com/labstack/echo/v4" + "github.com/teamhanko/hanko/backend/config" + "github.com/teamhanko/hanko/backend/ee/saml/dto" + "github.com/teamhanko/hanko/backend/persistence" + "net/http" +) + +type SamlAdminHandler interface { + List(ctx echo.Context) error + Create(ctx echo.Context) error + Get(ctx echo.Context) error + Update(ctx echo.Context) error + Delete(ctx echo.Context) error +} + +type samlAdminHandler struct { + cfg *config.Config + persister persistence.Persister +} + +const ( + validateRequestError = "unable to validate request" + bindRequestError = "unable to parse request" + parseIdError = "unable to parse provider id: %w" + providerNotFoundError = "unable to find provider" +) + +func NewSamlAdminHandler(cfg *config.Config, persister persistence.Persister) SamlAdminHandler { + return &samlAdminHandler{ + cfg: cfg, + persister: persister, + } +} + +func (s *samlAdminHandler) List(ctx echo.Context) error { + persister := s.persister.GetSamlIdentityProviderPersister(nil) + + providers, err := persister.List() + if err != nil { + ctx.Logger().Error(err) + + return err + } + + return ctx.JSON(http.StatusOK, providers) +} + +func (s *samlAdminHandler) Create(ctx echo.Context) error { + var createDto dto.SamlCreateProviderRequest + err := ctx.Bind(&createDto) + if err != nil { + ctx.Logger().Error(err) + return echo.NewHTTPError(http.StatusBadRequest, bindRequestError).SetInternal(err) + } + + err = ctx.Validate(&createDto) + if err != nil { + ctx.Logger().Error(err) + return echo.NewHTTPError(http.StatusBadRequest, validateRequestError).SetInternal(err) + } + + return s.persister.Transaction(func(tx *pop.Connection) error { + persister := s.persister.GetSamlIdentityProviderPersister(tx) + model, err := persister.GetByDomain(createDto.Domain) + if err != nil { + ctx.Logger().Error(err) + return fmt.Errorf("unable to fetch providers from database: %w", err) + } + + if model != nil { + return echo.NewHTTPError(http.StatusConflict, fmt.Sprintf("a provider with the domain '%s' already exists", createDto.Domain)) + } + + provider, err := createDto.ToModel() + if err != nil { + ctx.Logger().Error(err) + return err + } + + err = persister.Create(provider, &provider.AttributeMap) + if err != nil { + ctx.Logger().Error(err) + return err + } + + return ctx.JSON(http.StatusCreated, provider) + }) +} + +func (s *samlAdminHandler) Get(ctx echo.Context) error { + var getDto dto.SamlGetProviderRequest + err := ctx.Bind(&getDto) + if err != nil { + ctx.Logger().Error(err) + return echo.NewHTTPError(http.StatusBadRequest, bindRequestError).SetInternal(err) + } + + err = ctx.Validate(&getDto) + if err != nil { + ctx.Logger().Error(err) + return echo.NewHTTPError(http.StatusBadRequest, validateRequestError).SetInternal(err) + } + + providerId, err := uuid.FromString(getDto.ID) + if err != nil { + ctx.Logger().Error(err) + return fmt.Errorf(parseIdError, err) + } + + persister := s.persister.GetSamlIdentityProviderPersister(nil) + + provider, err := persister.Get(providerId) + if err != nil { + ctx.Logger().Error(err) + return fmt.Errorf("unable to fetch provider from db: %w", err) + } + + if provider == nil { + return echo.NewHTTPError(http.StatusNotFound, providerNotFoundError) + } + + return ctx.JSON(http.StatusOK, provider) +} + +func (s *samlAdminHandler) Update(ctx echo.Context) error { + var updateProviderDto dto.SamlUpdateProviderRequest + err := ctx.Bind(&updateProviderDto) + if err != nil { + ctx.Logger().Error(err) + return echo.NewHTTPError(http.StatusBadRequest, bindRequestError).SetInternal(err) + } + + err = ctx.Validate(&updateProviderDto) + if err != nil { + ctx.Logger().Error(err) + return echo.NewHTTPError(http.StatusBadRequest, validateRequestError).SetInternal(err) + } + + return s.persister.Transaction(func(tx *pop.Connection) error { + persister := s.persister.GetSamlIdentityProviderPersister(nil) + checkModel, err := persister.GetByDomain(updateProviderDto.Domain) + if err != nil { + ctx.Logger().Error(err) + return fmt.Errorf("unable to fetch providers from database: %w", err) + } + + providerId, err := uuid.FromString(updateProviderDto.ID) + if err != nil { + ctx.Logger().Error(err) + return fmt.Errorf(parseIdError, err) + } + + if checkModel != nil && checkModel.ID != providerId { + return echo.NewHTTPError(http.StatusConflict, fmt.Sprintf("a provider with the domain '%s' already exists", updateProviderDto.Domain)) + } + + updateModel, err := persister.Get(providerId) + if err != nil { + ctx.Logger().Error(err) + return err + } + + if updateModel == nil { + return echo.NewHTTPError(http.StatusNotFound, providerNotFoundError) + } + + updateModel = updateProviderDto.UpdateModelFromDto(updateModel) + + err = persister.Update(updateModel) + if err != nil { + ctx.Logger().Error(err) + return err + } + + return ctx.JSON(http.StatusOK, updateModel) + }) +} + +func (s *samlAdminHandler) Delete(ctx echo.Context) error { + var getDto dto.SamlGetProviderRequest + err := ctx.Bind(&getDto) + if err != nil { + ctx.Logger().Error(err) + return echo.NewHTTPError(http.StatusBadRequest, bindRequestError).SetInternal(err) + } + + err = ctx.Validate(&getDto) + if err != nil { + ctx.Logger().Error(err) + return echo.NewHTTPError(http.StatusBadRequest, validateRequestError).SetInternal(err) + } + + providerId, err := uuid.FromString(getDto.ID) + if err != nil { + ctx.Logger().Error(err) + return fmt.Errorf(parseIdError, err) + } + + persister := s.persister.GetSamlIdentityProviderPersister(nil) + + provider, err := persister.Get(providerId) + if err != nil { + ctx.Logger().Error(err) + return fmt.Errorf("unable to fetch provider from db: %w", err) + } + + if provider == nil { + return echo.NewHTTPError(http.StatusNotFound, providerNotFoundError) + } + + err = persister.Delete(provider) + if err != nil { + ctx.Logger().Error(err) + return fmt.Errorf("unable to delete provider from db: %w", err) + } + + return ctx.NoContent(http.StatusNoContent) +} diff --git a/backend/ee/saml/admin/handler_test.go b/backend/ee/saml/admin/handler_test.go new file mode 100644 index 000000000..27c1b2f09 --- /dev/null +++ b/backend/ee/saml/admin/handler_test.go @@ -0,0 +1,532 @@ +package admin + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/gofrs/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/suite" + "github.com/teamhanko/hanko/backend/config" + baseDto "github.com/teamhanko/hanko/backend/dto" + "github.com/teamhanko/hanko/backend/ee/saml/dto" + "github.com/teamhanko/hanko/backend/persistence/models" + "github.com/teamhanko/hanko/backend/test" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSamlAdminHandler(t *testing.T) { + t.Parallel() + suite.Run(t, new(samlAdminHandlerSuite)) +} + +type samlAdminHandlerSuite struct { + test.Suite + server *echo.Echo +} + +func (s *samlAdminHandlerSuite) setupServer() { + e := echo.New() + + e.Validator = baseDto.NewCustomValidator() + e.HTTPErrorHandler = baseDto.NewHTTPErrorHandler(baseDto.HTTPErrorHandlerConfig{Debug: false, Logger: e.Logger}) + + cfg := config.DefaultConfig() + handler := NewSamlAdminHandler(cfg, s.Storage) + + routingGroup := e.Group("saml") + routingGroup.GET("", handler.List) + routingGroup.POST("", handler.Create) + + singleProviderGroup := routingGroup.Group("/:id") + singleProviderGroup.GET("", handler.Get) + singleProviderGroup.PUT("", handler.Update) + singleProviderGroup.DELETE("", handler.Delete) + + s.server = e +} + +func (s *samlAdminHandlerSuite) TestSamlAdminHandler_New() { + handler := NewSamlAdminHandler(&config.Config{}, s.Storage) + s.Assert().NotEmpty(handler) +} + +func (s *samlAdminHandlerSuite) TestSamlAdminHandler_List() { + if testing.Short() { + s.T().Skip("skipping test in short mode") + } + + // given + err := s.LoadFixtures("../../../test/fixtures/saml") + s.Require().NoError(err) + + s.setupServer() + + expectedProviders, err := s.Storage.GetSamlIdentityProviderPersister(nil).List() + s.Require().NoError(err) + + // when + req := httptest.NewRequest(http.MethodGet, "/saml", nil) + rec := httptest.NewRecorder() + + s.server.ServeHTTP(rec, req) + + var providers models.SamlIdentityProviders + err = json.Unmarshal(rec.Body.Bytes(), &providers) + s.Require().NoError(err) + + // then + s.Assert().Equal(http.StatusOK, rec.Code) + s.Assert().Len(providers, 2) + s.Assert().Equal(expectedProviders[0].ID, providers[0].ID) + s.Assert().Equal(expectedProviders[1].ID, providers[1].ID) +} + +func (s *samlAdminHandlerSuite) TestSamlAdminHandler_ListWithEmptyDb() { + if testing.Short() { + s.T().Skip("skipping test in short mode") + } + + // given + s.setupServer() + + // when + req := httptest.NewRequest(http.MethodGet, "/saml", nil) + rec := httptest.NewRecorder() + + s.server.ServeHTTP(rec, req) + + var providers models.SamlIdentityProviders + err := json.Unmarshal(rec.Body.Bytes(), &providers) + s.Require().NoError(err) + + // then + s.Assert().Equal(http.StatusOK, rec.Code) + s.Assert().Len(providers, 0) +} + +func (s *samlAdminHandlerSuite) TestSamlAdminHandler_Create() { + if testing.Short() { + s.T().Skip("skipping test in short mode") + } + + err := s.LoadFixtures("../../../test/fixtures/saml") + s.Require().NoError(err) + + s.setupServer() + + tests := []struct { + Name string + Data interface{} + ExpectedCode int + ExpectedError string + ContainsError bool + }{ + { + Name: "Success", + Data: dto.SamlCreateProviderRequest{ + Enabled: true, + Name: "Testprovider", + Domain: "hanko2.io", + MetadataUrl: "https://hanko.io/metadata", + SkipEmailVerification: false, + AttributeMap: &dto.SamlCreateProviderAttributeMapRequest{ + Name: "name", + FamilyName: "family_name", + GivenName: "given_name", + MiddleName: "middle_name", + NickName: "nickname", + PreferredUsername: "preferred_username", + Profile: "profile", + Picture: "picture", + Website: "website", + Gender: "gender", + Birthdate: "born_at", + ZoneInfo: "info", + Locale: "locale", + UpdatedAt: "last_update", + Email: "email", + EmailVerified: "verified_email", + Phone: "phone", + PhoneVerified: "verified_phone", + }, + }, + ExpectedCode: http.StatusCreated, + ContainsError: false, + }, + { + Name: "Bind Error", + Data: "lorem", + ExpectedCode: http.StatusBadRequest, + ExpectedError: bindRequestError, + ContainsError: true, + }, + { + Name: "Validation Error", + Data: dto.SamlCreateProviderRequest{ + Enabled: true, + Name: "Testprovider", + Domain: "hanko2.io", + MetadataUrl: "aaa", + SkipEmailVerification: false, + }, + ExpectedCode: http.StatusBadRequest, + ExpectedError: validateRequestError, + ContainsError: true, + }, + { + Name: "Already Existing Domain Error", + Data: dto.SamlCreateProviderRequest{ + Enabled: true, + Name: "Testprovider", + Domain: "hanko.io", + MetadataUrl: "https://hanko.io/metadata", + SkipEmailVerification: false, + }, + ExpectedCode: http.StatusConflict, + ExpectedError: "a provider with the domain 'hanko.io' already exists", + ContainsError: true, + }, + } + + for _, samlTest := range tests { + s.T().Run(samlTest.Name, func(t *testing.T) { + // given + dataJson, err := json.Marshal(samlTest.Data) + s.Require().NoError(err) + + // when + req := httptest.NewRequest(http.MethodPost, "/saml", bytes.NewReader(dataJson)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + s.server.ServeHTTP(rec, req) + + // then + s.Assert().Equal(samlTest.ExpectedCode, rec.Code) + if samlTest.ContainsError { + var hErr echo.HTTPError + err = json.Unmarshal(rec.Body.Bytes(), &hErr) + s.Require().NoError(err) + + s.Assert().Equal(samlTest.ExpectedError, hErr.Message) + + } else { + data := samlTest.Data.(dto.SamlCreateProviderRequest) + + provider, err := s.Storage.GetSamlIdentityProviderPersister(nil).GetByDomain(data.Domain) + s.Require().NoError(err) + + s.Assert().NotNil(provider.ID) + s.Assert().Equal(data.Enabled, provider.Enabled) + s.Assert().Equal(data.Name, provider.Name) + s.Assert().Equal(data.Domain, provider.Domain) + s.Assert().Equal(data.MetadataUrl, provider.MetadataUrl) + s.Assert().Equal(data.SkipEmailVerification, provider.SkipEmailVerification) + s.Assert().Equal(data.AttributeMap.Name, provider.AttributeMap.Name) + s.Assert().Equal(data.AttributeMap.FamilyName, provider.AttributeMap.FamilyName) + s.Assert().Equal(data.AttributeMap.GivenName, provider.AttributeMap.GivenName) + s.Assert().Equal(data.AttributeMap.MiddleName, provider.AttributeMap.MiddleName) + s.Assert().Equal(data.AttributeMap.NickName, provider.AttributeMap.NickName) + s.Assert().Equal(data.AttributeMap.PreferredUsername, provider.AttributeMap.PreferredUsername) + s.Assert().Equal(data.AttributeMap.Profile, provider.AttributeMap.Profile) + s.Assert().Equal(data.AttributeMap.Picture, provider.AttributeMap.Picture) + s.Assert().Equal(data.AttributeMap.Website, provider.AttributeMap.Website) + s.Assert().Equal(data.AttributeMap.Gender, provider.AttributeMap.Gender) + s.Assert().Equal(data.AttributeMap.Birthdate, provider.AttributeMap.Birthdate) + s.Assert().Equal(data.AttributeMap.ZoneInfo, provider.AttributeMap.ZoneInfo) + s.Assert().Equal(data.AttributeMap.Locale, provider.AttributeMap.Locale) + s.Assert().Equal(data.AttributeMap.UpdatedAt, provider.AttributeMap.SamlUpdatedAt) + s.Assert().Equal(data.AttributeMap.Email, provider.AttributeMap.Email) + s.Assert().Equal(data.AttributeMap.EmailVerified, provider.AttributeMap.EmailVerified) + s.Assert().Equal(data.AttributeMap.Phone, provider.AttributeMap.Phone) + s.Assert().Equal(data.AttributeMap.PhoneVerified, provider.AttributeMap.PhoneVerified) + } + }) + } +} + +func (s *samlAdminHandlerSuite) TestSamlAdminHandler_Get() { + if testing.Short() { + s.T().Skip("skipping test in short mode") + } + + err := s.LoadFixtures("../../../test/fixtures/saml") + s.Require().NoError(err) + + s.setupServer() + + tests := []struct { + Name string + ProviderId string + ContainsError bool + ExpectedCode int + ExpectedErrorMessage string + }{ + { + Name: "Success", + ProviderId: "d531b0ae-4c33-48bb-ad31-e800a71a5056", + ExpectedCode: http.StatusOK, + ContainsError: false, + }, + { + Name: "Validation Error", + ProviderId: "lorem", + ContainsError: true, + ExpectedCode: http.StatusBadRequest, + ExpectedErrorMessage: validateRequestError, + }, + { + Name: "Not Found Error", + ProviderId: "00000000-4c33-48bb-ad31-e800a71a5056", + ContainsError: true, + ExpectedCode: http.StatusNotFound, + ExpectedErrorMessage: providerNotFoundError, + }, + } + + for _, samlTest := range tests { + s.T().Run(samlTest.Name, func(t *testing.T) { + // when + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/saml/%s", samlTest.ProviderId), nil) + rec := httptest.NewRecorder() + + s.server.ServeHTTP(rec, req) + + // then + s.Assert().Equal(samlTest.ExpectedCode, rec.Code) + + if samlTest.ContainsError { + var hErr echo.HTTPError + err = json.Unmarshal(rec.Body.Bytes(), &hErr) + s.Require().NoError(err) + + s.Assert().Equal(samlTest.ExpectedErrorMessage, hErr.Message) + } else { + var provider models.SamlIdentityProvider + err := json.Unmarshal(rec.Body.Bytes(), &provider) + s.Require().NoError(err) + + s.Assert().Equal(samlTest.ProviderId, provider.ID.String()) + s.Assert().True(provider.Enabled) + s.Assert().False(provider.SkipEmailVerification) + s.Assert().Equal("hanko", provider.Name) + s.Assert().Equal("hanko.io", provider.Domain) + s.Assert().Equal("https://localhost/metadata", provider.MetadataUrl) + } + }) + } +} + +func (s *samlAdminHandlerSuite) TestSamlAdminHandler_Update() { + if testing.Short() { + s.T().Skip("skipping test in short mode") + } + + err := s.LoadFixtures("../../../test/fixtures/saml") + s.Require().NoError(err) + + s.setupServer() + + tests := []struct { + Name string + ProviderId string + ProviderData interface{} + ExpectedCode int + ExpectedErrorMessage string + ContainsError bool + }{ + { + Name: "Success", + ProviderId: "d531b0ae-4c33-48bb-ad31-e800a71a5056", + ProviderData: dto.SamlCreateProviderRequest{ + Enabled: true, + Name: "Ipsum", + Domain: "test.de", + MetadataUrl: "http://fqdn.loc/metadata", + SkipEmailVerification: false, + AttributeMap: &dto.SamlCreateProviderAttributeMapRequest{ + Name: "home", + }, + }, + ExpectedCode: http.StatusOK, + ContainsError: false, + }, + { + Name: "No domain change success", + ProviderId: "d531b0ae-4c33-48bb-ad31-e800a71a5056", + ProviderData: dto.SamlCreateProviderRequest{ + Enabled: true, + Name: "Ipsum", + Domain: "hanko.io", + MetadataUrl: "http://fqdn.loc/metadata", + SkipEmailVerification: false, + AttributeMap: &dto.SamlCreateProviderAttributeMapRequest{ + Name: "home", + }, + }, + ExpectedCode: http.StatusOK, + ContainsError: false, + }, + { + Name: "Bind request error", + ProviderId: "d531b0ae-4c33-48bb-ad31-e800a71a5056", + ProviderData: "gibberish", + ExpectedCode: http.StatusBadRequest, + ExpectedErrorMessage: bindRequestError, + ContainsError: true, + }, + { + Name: "validate request error", + ProviderId: "d531b0ae-4c33-48bb-ad31-e800a71a5056", + ProviderData: dto.SamlCreateProviderRequest{ + Name: "test", + }, + ExpectedCode: http.StatusBadRequest, + ExpectedErrorMessage: validateRequestError, + ContainsError: true, + }, + { + Name: "not found error", + ProviderId: "00000000-4c33-48bb-ad31-e800a71a5056", + ProviderData: dto.SamlCreateProviderRequest{ + Enabled: true, + Name: "Ipsum", + Domain: "test.de", + MetadataUrl: "http://fqdn.loc/metadata", + SkipEmailVerification: false, + AttributeMap: &dto.SamlCreateProviderAttributeMapRequest{ + Name: "home", + }, + }, + ExpectedCode: http.StatusNotFound, + ExpectedErrorMessage: providerNotFoundError, + ContainsError: true, + }, + { + Name: "conflict error", + ProviderId: "d531b0ae-4c33-48bb-ad31-e800a71a5056", + ProviderData: dto.SamlCreateProviderRequest{ + Enabled: true, + Name: "Ipsum", + Domain: "localhost", + MetadataUrl: "http://fqdn.loc/metadata", + SkipEmailVerification: false, + AttributeMap: &dto.SamlCreateProviderAttributeMapRequest{ + Name: "home", + }, + }, + ExpectedCode: http.StatusConflict, + ExpectedErrorMessage: "a provider with the domain 'localhost' already exists", + ContainsError: true, + }, + } + + for _, samlTest := range tests { + s.setupServer() + + dataJson, err := json.Marshal(samlTest.ProviderData) + s.Require().NoError(err) + + // when + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/saml/%s", samlTest.ProviderId), bytes.NewReader(dataJson)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + s.server.ServeHTTP(rec, req) + + s.Assert().Equal(samlTest.ExpectedCode, rec.Code) + + if samlTest.ContainsError { + var hErr echo.HTTPError + err = json.Unmarshal(rec.Body.Bytes(), &hErr) + s.Require().NoError(err) + + s.Assert().Equal(samlTest.ExpectedErrorMessage, hErr.Message) + } else { + providerUUid, err := uuid.FromString(samlTest.ProviderId) + s.Require().NoError(err) + + provider, err := s.Storage.GetSamlIdentityProviderPersister(nil).Get(providerUUid) + s.Require().NoError(err) + + testDto := samlTest.ProviderData.(dto.SamlCreateProviderRequest) + + s.Assert().Equal(testDto.Name, provider.Name) + s.Assert().Equal(testDto.Domain, provider.Domain) + s.Assert().Equal(testDto.MetadataUrl, provider.MetadataUrl) + s.Assert().Equal(testDto.Enabled, provider.Enabled) + s.Assert().Equal(testDto.SkipEmailVerification, provider.SkipEmailVerification) + s.Assert().Equal(testDto.AttributeMap.Name, provider.AttributeMap.Name) + s.Assert().Equal(testDto.AttributeMap.Profile, provider.AttributeMap.Profile) + } + } +} + +func (s *samlAdminHandlerSuite) TestSamlAdminHandler_Delete() { + if testing.Short() { + s.T().Skip("skipping test in short mode") + } + + err := s.LoadFixtures("../../../test/fixtures/saml") + s.Require().NoError(err) + + s.setupServer() + + tests := []struct { + Name string + ProviderId string + ContainsError bool + ExpectedCode int + ExpectedErrorMessage string + }{ + { + Name: "Success", + ProviderId: "d531b0ae-4c33-48bb-ad31-e800a71a5056", + ExpectedCode: http.StatusNoContent, + ContainsError: false, + }, + { + Name: "Validation Error", + ProviderId: "lorem", + ContainsError: true, + ExpectedCode: http.StatusBadRequest, + ExpectedErrorMessage: validateRequestError, + }, + { + Name: "Not Found Error", + ProviderId: "00000000-4c33-48bb-ad31-e800a71a5056", + ContainsError: true, + ExpectedCode: http.StatusNotFound, + ExpectedErrorMessage: providerNotFoundError, + }, + } + + for _, samlTest := range tests { + s.T().Run(samlTest.Name, func(t *testing.T) { + // when + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/saml/%s", samlTest.ProviderId), nil) + rec := httptest.NewRecorder() + + s.server.ServeHTTP(rec, req) + + // then + s.Assert().Equal(samlTest.ExpectedCode, rec.Code) + + if samlTest.ContainsError { + var hErr echo.HTTPError + err = json.Unmarshal(rec.Body.Bytes(), &hErr) + s.Require().NoError(err) + + s.Assert().Equal(samlTest.ExpectedErrorMessage, hErr.Message) + } else { + providers, err := s.Storage.GetSamlIdentityProviderPersister(nil).List() + s.Require().NoError(err) + + s.Assert().Len(providers, 1) + } + }) + } +} diff --git a/backend/ee/saml/config/saml.go b/backend/ee/saml/config/saml.go index bb8706f3b..6e5099121 100644 --- a/backend/ee/saml/config/saml.go +++ b/backend/ee/saml/config/saml.go @@ -88,10 +88,6 @@ func (s *Saml) Validate() error { return validationErrors } - if len(s.IdentityProviders) == 0 { - return errors.New("at least one SAML provider is needed") - } - for _, provider := range s.IdentityProviders { validationErrors = provider.Validate() if validationErrors != nil { diff --git a/backend/ee/saml/dto/saml.go b/backend/ee/saml/dto/saml.go index 99ad02db8..43e2480ae 100644 --- a/backend/ee/saml/dto/saml.go +++ b/backend/ee/saml/dto/saml.go @@ -1,5 +1,12 @@ package dto +import ( + "fmt" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/persistence/models" + "time" +) + type SamlRequest struct { Domain string `query:"domain" validate:"required,fqdn"` } @@ -13,3 +20,136 @@ type SamlAuthRequest struct { SamlRequest RedirectTo string `query:"redirect_to" validate:"required,url"` } + +type SamlCreateProviderAttributeMapRequest struct { + Name string `json:"name" validate:"omitempty"` + FamilyName string `json:"family_name" validate:"omitempty"` + GivenName string `json:"given_name" validate:"omitempty"` + MiddleName string `json:"middle_name" validate:"omitempty"` + NickName string `json:"nickname" validate:"omitempty"` + PreferredUsername string `json:"preferred_username" validate:"omitempty"` + Profile string `json:"profile" validate:"omitempty"` + Picture string `json:"picture" validate:"omitempty"` + Website string `json:"website" validate:"omitempty"` + Gender string `json:"gender" validate:"omitempty"` + Birthdate string `json:"birthdate" validate:"omitempty"` + ZoneInfo string `json:"zone_info" validate:"omitempty"` + Locale string `json:"locale" validate:"omitempty"` + UpdatedAt string `json:"updated_at" validate:"omitempty"` + Email string `json:"email" validate:"omitempty"` + EmailVerified string `json:"email_verified" validate:"omitempty"` + Phone string `json:"phone" validate:"omitempty"` + PhoneVerified string `json:"phone_verified" validate:"omitempty"` +} + +func (sam *SamlCreateProviderAttributeMapRequest) ToModel(model *models.SamlAttributeMap) *models.SamlAttributeMap { + model.Name = sam.Name + model.FamilyName = sam.FamilyName + model.GivenName = sam.GivenName + model.MiddleName = sam.MiddleName + model.NickName = sam.NickName + model.PreferredUsername = sam.PreferredUsername + model.Profile = sam.Profile + model.Picture = sam.Picture + model.Website = sam.Website + model.Gender = sam.Gender + model.Birthdate = sam.Birthdate + model.ZoneInfo = sam.ZoneInfo + model.Locale = sam.Locale + model.SamlUpdatedAt = sam.UpdatedAt + model.Email = sam.Email + model.EmailVerified = sam.EmailVerified + model.Phone = sam.Phone + model.PhoneVerified = sam.PhoneVerified + + return model +} + +type SamlCreateProviderRequest struct { + Enabled bool `json:"enabled" validate:"omitempty,boolean"` + Name string `json:"name" validate:"required,min=5"` + Domain string `json:"domain" validate:"required,hostname_rfc1123"` + MetadataUrl string `json:"metadata_url" validate:"required,url"` + SkipEmailVerification bool `json:"skip_email_verification" validate:"omitempty,boolean"` + AttributeMap *SamlCreateProviderAttributeMapRequest `json:"attribute_map" validate:"omitempty"` +} + +func (s *SamlCreateProviderRequest) ToModel() (*models.SamlIdentityProvider, error) { + now := time.Now() + + providerId, err := uuid.NewV4() + if err != nil { + return nil, fmt.Errorf("unable to generate uuid: %w", err) + } + + attributeMapId, err := uuid.NewV4() + if err != nil { + return nil, fmt.Errorf("unable to generate uuid: %w", err) + } + + attributeMapModel := &models.SamlAttributeMap{ + ID: attributeMapId, + IdentityProviderID: providerId, + CreatedAt: now, + UpdatedAt: now, + } + + if s.AttributeMap != nil { + attributeMapModel = s.AttributeMap.ToModel(attributeMapModel) + } + + provider := models.SamlIdentityProvider{ + ID: providerId, + AttributeMap: *attributeMapModel, + Enabled: s.Enabled, + Name: s.Name, + Domain: s.Domain, + MetadataUrl: s.MetadataUrl, + SkipEmailVerification: s.SkipEmailVerification, + CreatedAt: now, + UpdatedAt: now, + } + + return &provider, nil +} + +type SamlGetProviderRequest struct { + ID string `param:"id" validate:"required,uuid4"` +} + +type SamlUpdateProviderRequest struct { + SamlGetProviderRequest + SamlCreateProviderRequest +} + +func (su *SamlUpdateProviderRequest) UpdateModelFromDto(model *models.SamlIdentityProvider) *models.SamlIdentityProvider { + now := time.Now() + + model.Enabled = su.Enabled + model.Name = su.Name + model.Domain = su.Domain + model.MetadataUrl = su.MetadataUrl + model.SkipEmailVerification = su.SkipEmailVerification + model.UpdatedAt = now + + model.AttributeMap.Name = su.AttributeMap.Name + model.AttributeMap.FamilyName = su.AttributeMap.FamilyName + model.AttributeMap.GivenName = su.AttributeMap.GivenName + model.AttributeMap.MiddleName = su.AttributeMap.MiddleName + model.AttributeMap.NickName = su.AttributeMap.NickName + model.AttributeMap.PreferredUsername = su.AttributeMap.PreferredUsername + model.AttributeMap.Profile = su.AttributeMap.Profile + model.AttributeMap.Picture = su.AttributeMap.Picture + model.AttributeMap.Website = su.AttributeMap.Website + model.AttributeMap.Gender = su.AttributeMap.Gender + model.AttributeMap.Birthdate = su.AttributeMap.Birthdate + model.AttributeMap.ZoneInfo = su.AttributeMap.ZoneInfo + model.AttributeMap.Locale = su.AttributeMap.Locale + model.AttributeMap.SamlUpdatedAt = su.AttributeMap.UpdatedAt + model.AttributeMap.Email = su.AttributeMap.Email + model.AttributeMap.EmailVerified = su.AttributeMap.EmailVerified + model.AttributeMap.Phone = su.AttributeMap.Phone + model.AttributeMap.PhoneVerified = su.AttributeMap.PhoneVerified + + return model +} diff --git a/backend/ee/saml/handler.go b/backend/ee/saml/handler.go index 859bfa41b..811c273e2 100644 --- a/backend/ee/saml/handler.go +++ b/backend/ee/saml/handler.go @@ -8,6 +8,7 @@ import ( saml2 "github.com/russellhaering/gosaml2" auditlog "github.com/teamhanko/hanko/backend/audit_log" "github.com/teamhanko/hanko/backend/config" + samlConfig "github.com/teamhanko/hanko/backend/ee/saml/config" "github.com/teamhanko/hanko/backend/ee/saml/dto" "github.com/teamhanko/hanko/backend/ee/saml/provider" samlUtils "github.com/teamhanko/hanko/backend/ee/saml/utils" @@ -16,6 +17,7 @@ import ( "github.com/teamhanko/hanko/backend/session" "github.com/teamhanko/hanko/backend/thirdparty" "github.com/teamhanko/hanko/backend/utils" + "golang.org/x/exp/slices" "net/http" "net/url" "strings" @@ -29,22 +31,21 @@ type SamlHandler struct { providers []provider.ServiceProvider } +const ( + unableToLoadProviderError = "unable to load providers" + metadataErrorMessage = "unable to provide metadata" +) + func NewSamlHandler(cfg *config.Config, persister persistence.Persister, sessionManager session.Manager, auditLogger auditlog.Logger) *SamlHandler { providers := make([]provider.ServiceProvider, 0) for _, idpConfig := range cfg.Saml.IdentityProviders { if idpConfig.Enabled { - name := "" - name, err := parseProviderFromMetadataUrl(idpConfig.MetadataUrl) + newProvider, err := initializeServiceProvider(idpConfig, cfg, persister) if err != nil { panic(err) } - newProvider, err := provider.GetProvider(name, cfg, idpConfig, persister.GetSamlCertificatePersister()) - if err != nil { - panic(err) - } - - providers = append(providers, newProvider) + providers = append(providers, *newProvider) } } @@ -57,6 +58,21 @@ func NewSamlHandler(cfg *config.Config, persister persistence.Persister, session } } +func initializeServiceProvider(idpConfig samlConfig.IdentityProvider, cfg *config.Config, persister persistence.Persister) (*provider.ServiceProvider, error) { + name := "" + name, err := parseProviderFromMetadataUrl(idpConfig.MetadataUrl) + if err != nil { + return nil, err + } + + newProvider, err := provider.GetProvider(name, cfg, idpConfig, persister.GetSamlCertificatePersister()) + if err != nil { + return nil, err + } + + return &newProvider, nil +} + func parseProviderFromMetadataUrl(idpUrlString string) (string, error) { idpUrl, err := url.Parse(idpUrlString) if err != nil { @@ -66,8 +82,12 @@ func parseProviderFromMetadataUrl(idpUrlString string) (string, error) { return idpUrl.Host, nil } -func (handler *SamlHandler) getProviderByDomain(domain string) (provider.ServiceProvider, error) { - for _, availableProvider := range handler.providers { +func (handler *SamlHandler) getProviderByDomain(domain string, providers []provider.ServiceProvider) (provider.ServiceProvider, error) { + if len(providers) == 0 { + return nil, errors.New("no provider configured") + } + + for _, availableProvider := range providers { if availableProvider.GetDomain() == domain { return availableProvider, nil } @@ -84,17 +104,22 @@ func (handler *SamlHandler) Metadata(c echo.Context) error { return c.JSON(http.StatusBadRequest, thirdparty.ErrorInvalidRequest("domain is missing")) } - foundProvider, err := handler.getProviderByDomain(request.Domain) + providerList, err := handler.addDbProviders(c) + if err != nil { + return c.JSON(http.StatusInternalServerError, thirdparty.ErrorServer(metadataErrorMessage).WithCause(err)) + } + + foundProvider, err := handler.getProviderByDomain(request.Domain, providerList) if err != nil { c.Logger().Error(err) - return c.NoContent(http.StatusNotFound) + return echo.NewHTTPError(http.StatusNotFound, err) } if request.CertOnly { cert, err := handler.persister.GetSamlCertificatePersister().GetFirst() if err != nil { c.Logger().Error(err) - return c.JSON(http.StatusInternalServerError, thirdparty.ErrorServer("unable to provide metadata").WithCause(err)) + return c.JSON(http.StatusInternalServerError, thirdparty.ErrorServer(metadataErrorMessage).WithCause(err)) } if cert == nil { @@ -108,7 +133,7 @@ func (handler *SamlHandler) Metadata(c echo.Context) error { xmlMetadata, err := foundProvider.ProvideMetadataAsXml() if err != nil { c.Logger().Error(err) - return c.JSON(http.StatusInternalServerError, thirdparty.ErrorServer("unable to provide metadata").WithCause(err)) + return c.JSON(http.StatusInternalServerError, thirdparty.ErrorServer(metadataErrorMessage).WithCause(err)) } c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s-metadata.xml", handler.config.Service.Name)) @@ -138,7 +163,12 @@ func (handler *SamlHandler) Auth(c echo.Context) error { return handler.redirectError(c, thirdparty.ErrorInvalidRequest(fmt.Sprintf("redirect to '%s' not allowed", request.RedirectTo)), errorRedirectTo) } - foundProvider, err := handler.getProviderByDomain(request.Domain) + providerList, err := handler.addDbProviders(c) + if err != nil { + return c.JSON(http.StatusInternalServerError, thirdparty.ErrorServer(unableToLoadProviderError).WithCause(err)) + } + + foundProvider, err := handler.getProviderByDomain(request.Domain, providerList) if err != nil { c.Logger().Error(err) return handler.redirectError(c, thirdparty.ErrorInvalidRequest(err.Error()).WithCause(err), errorRedirectTo) @@ -189,12 +219,17 @@ func (handler *SamlHandler) CallbackPost(c echo.Context) error { ) } - foundProvider, samlError := handler.getProviderByDomain(state.Provider) - if samlError != nil { - c.Logger().Error(samlError) + providerList, err := handler.addDbProviders(c) + if err != nil { + return c.JSON(http.StatusInternalServerError, thirdparty.ErrorServer(unableToLoadProviderError).WithCause(err)) + } + + foundProvider, err := handler.getProviderByDomain(state.Provider, providerList) + if err != nil { + c.Logger().Error(err) return handler.redirectError( c, - thirdparty.ErrorServer("unable to find provider by domain").WithCause(samlError), + thirdparty.ErrorServer("unable to find provider by domain").WithCause(err), redirectTo.String(), ) } @@ -327,11 +362,79 @@ func (handler *SamlHandler) GetProvider(c echo.Context) error { return c.JSON(http.StatusBadRequest, err) } - foundProvider, err := handler.getProviderByDomain(request.Domain) + providerList, err := handler.addDbProviders(c) + if err != nil { + return c.JSON(http.StatusInternalServerError, thirdparty.ErrorServer(unableToLoadProviderError).WithCause(err)) + } + + foundProvider, err := handler.getProviderByDomain(request.Domain, providerList) if err != nil { c.Logger().Error(err) - return c.NoContent(http.StatusNotFound) + return echo.NewHTTPError(http.StatusNotFound, err) } return c.JSON(http.StatusOK, foundProvider.GetConfig()) } + +func (handler *SamlHandler) addDbProviders(ctx echo.Context) ([]provider.ServiceProvider, error) { + serviceProviders := handler.providers + + dbProviders, err := handler.persister.GetSamlIdentityProviderPersister(nil).List() + if err != nil { + ctx.Logger().Error(err) + return nil, err + } + + for _, dbProvider := range dbProviders { + if dbProvider.Enabled { + isAlreadyRegistered := slices.ContainsFunc(handler.providers, func(idp provider.ServiceProvider) bool { + return idp.GetDomain() == dbProvider.Domain + }) + + if isAlreadyRegistered { + ctx.Logger().Warn("Provider with domain is already registered from config file") + continue + } + + attributeMap := samlConfig.AttributeMap{ + Name: dbProvider.AttributeMap.Name, + FamilyName: dbProvider.AttributeMap.FamilyName, + GivenName: dbProvider.AttributeMap.GivenName, + MiddleName: dbProvider.AttributeMap.MiddleName, + NickName: dbProvider.AttributeMap.NickName, + PreferredUsername: dbProvider.AttributeMap.PreferredUsername, + Profile: dbProvider.AttributeMap.Profile, + Picture: dbProvider.AttributeMap.Picture, + Website: dbProvider.AttributeMap.Website, + Gender: dbProvider.AttributeMap.Gender, + Birthdate: dbProvider.AttributeMap.Birthdate, + ZoneInfo: dbProvider.AttributeMap.ZoneInfo, + Locale: dbProvider.AttributeMap.Locale, + UpdatedAt: dbProvider.AttributeMap.SamlUpdatedAt, + Email: dbProvider.AttributeMap.Email, + EmailVerified: dbProvider.AttributeMap.EmailVerified, + Phone: dbProvider.AttributeMap.Phone, + PhoneVerified: dbProvider.AttributeMap.PhoneVerified, + } + + mappedProvider := samlConfig.IdentityProvider{ + Enabled: dbProvider.Enabled, + Name: dbProvider.Name, + Domain: dbProvider.Domain, + MetadataUrl: dbProvider.MetadataUrl, + SkipEmailVerification: dbProvider.SkipEmailVerification, + AttributeMap: attributeMap, + } + + sp, err := initializeServiceProvider(mappedProvider, handler.config, handler.persister) + if err != nil { + ctx.Logger().Error(err) + return nil, err + } + + serviceProviders = append(serviceProviders, *sp) + } + } + + return serviceProviders, nil +} diff --git a/backend/ee/saml/provider/auth0.go b/backend/ee/saml/provider/auth0.go index 5726de8a3..619f7e8c2 100644 --- a/backend/ee/saml/provider/auth0.go +++ b/backend/ee/saml/provider/auth0.go @@ -4,6 +4,7 @@ import ( "github.com/teamhanko/hanko/backend/config" samlConfig "github.com/teamhanko/hanko/backend/ee/saml/config" "github.com/teamhanko/hanko/backend/persistence" + "strings" ) type Auth0Provider struct { @@ -11,7 +12,7 @@ type Auth0Provider struct { } func NewAuth0ServiceProvider(config *config.Config, idpConfig samlConfig.IdentityProvider, persister persistence.SamlCertificatePersister) (ServiceProvider, error) { - serviceProvider, err := NewBaseSamlProvider(config, idpConfig, persister) + serviceProvider, err := NewBaseSamlProvider(config, idpConfig, persister, false) if err != nil { return nil, err } @@ -27,27 +28,27 @@ func NewAuth0ServiceProvider(config *config.Config, idpConfig samlConfig.Identit func (sp *Auth0Provider) UseDefaultAttributesIfEmpty() { attributeMap := &sp.Config.AttributeMap - if attributeMap.Name == "" { + if strings.TrimSpace(attributeMap.Name) == "" { attributeMap.Name = "http://schemas.auth0.com/name" } - if attributeMap.Email == "" { - attributeMap.Name = "http://schemas.auth0.com/email" + if strings.TrimSpace(attributeMap.Email) == "" { + attributeMap.Email = "http://schemas.auth0.com/email" } - if attributeMap.EmailVerified == "" { + if strings.TrimSpace(attributeMap.EmailVerified) == "" { attributeMap.EmailVerified = "http://schemas.auth0.com/email_verified" } - if attributeMap.NickName == "" { + if strings.TrimSpace(attributeMap.NickName) == "" { attributeMap.NickName = "http://schemas.auth0.com/nickname" } - if attributeMap.Picture == "" { + if strings.TrimSpace(attributeMap.Picture) == "" { attributeMap.Picture = "http://schemas.auth0.com/picture" } - if attributeMap.UpdatedAt == "" { + if strings.TrimSpace(attributeMap.UpdatedAt) == "" { attributeMap.UpdatedAt = "http://schemas.auth0.com/updated_at" } } diff --git a/backend/ee/saml/provider/provider.go b/backend/ee/saml/provider/provider.go index 46cf82646..80f8d3be4 100644 --- a/backend/ee/saml/provider/provider.go +++ b/backend/ee/saml/provider/provider.go @@ -137,5 +137,5 @@ func GetProvider(providerName string, cfg *config.Config, idpConfig samlConfig.I return NewAuth0ServiceProvider(cfg, idpConfig, persister) } - return NewBaseSamlProvider(cfg, idpConfig, persister) + return NewBaseSamlProvider(cfg, idpConfig, persister, true) } diff --git a/backend/ee/saml/provider/provider_test.go b/backend/ee/saml/provider/provider_test.go new file mode 100644 index 000000000..6e182a839 --- /dev/null +++ b/backend/ee/saml/provider/provider_test.go @@ -0,0 +1,358 @@ +package provider + +import ( + "encoding/xml" + "fmt" + "github.com/russellhaering/gosaml2/types" + dsigTypes "github.com/russellhaering/goxmldsig/types" + "github.com/stretchr/testify/suite" + "github.com/teamhanko/hanko/backend/config" + samlConfig "github.com/teamhanko/hanko/backend/ee/saml/config" + "github.com/teamhanko/hanko/backend/test" + "net/http" + "net/http/httptest" + "testing" +) + +func TestProviderSuite(t *testing.T) { + t.Parallel() + suite.Run(t, new(providerSuite)) +} + +type providerSuite struct { + test.Suite +} + +func (s *providerSuite) TestProvider_loadCertificate() { + if testing.Short() { + s.T().Skip("skipping test in short mode") + } + + err := s.LoadFixtures("../../../test/fixtures/saml") + s.Require().NoError(err) + + cfg := config.DefaultConfig() + + store, err := loadCertificate(cfg, s.Storage.GetSamlCertificatePersister()) + s.Require().NoError(err) + + s.Assert().NotNil(store) +} + +func (s *providerSuite) TestProvider_Create_Cert_On_Load() { + if testing.Short() { + s.T().Skip("skipping test in short mode") + } + + cfg := config.DefaultConfig() + + store, err := loadCertificate(cfg, s.Storage.GetSamlCertificatePersister()) + s.Require().NoError(err) + + s.Assert().NotNil(store) + + cert, err := s.Storage.GetSamlCertificatePersister().GetFirst() + s.Require().NoError(err) + + s.Assert().NotNil(cert) + s.Assert().NotNil(cert.CertData) +} + +func (s *providerSuite) TestProvider_Fetch_IDP_Metadata() { + // given + meta := &types.EntityDescriptor{ + EntityID: "Lorem", + IDPSSODescriptor: &types.IDPSSODescriptor{ + KeyDescriptors: []types.KeyDescriptor{ + { + KeyInfo: dsigTypes.KeyInfo{ + X509Data: dsigTypes.X509Data{ + X509Certificates: []dsigTypes.X509Certificate{ + { + Data: "MIIDCDCCAfCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAnMSUwIwYDVQQDExxIYW5rbyBBdXRoZW50aWNhdGlvbiBTZXJ2aWNlMB4XDTI0MDIxMjA5MjE1OFoXDTI1MDIxMTA5MjE1OFowJzElMCMGA1UEAxMcSGFua28gQXV0aGVudGljYXRpb24gU2VydmljZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKFFKmsZSTgOvFiVySkkkNYvuXb7cnv2d74uBPQzCJFU6RY7fRAFOPRmkZ+ICFIOW25/k8S6bn1igcPyAHhMMAuVNO/S1uTAM+A+lkqkyKsjt1L5qrMYbqLXhBd1hMgsEi0jIzGzXFtX4h2B4dd5CtZd0oUHTxWC1Sv7bq0vt5CqcSRaGWN83HHkRySZ+tjtvfTNemzLqvmoQrDBWukL0XJnOs/sbw55sq2oNORQKpwinjGcNoJfrvEgDVXVDrSixHtx5RX03QRn3N7o+dhCDIcp7yHx5n2GEcqLrCY9lniwLZZZ5IgWSOAwZK9N8WLlg6RJad1eIZ8ovdPYSuDMY70CAwEAAaM/MD0wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCFU4elOcts4yngWHzolom3t2VC4IQjFwoh59qyXy0cYRgfLrclKdpxgPO556iG/G/UfTbH1sD9cZlmMAIMzFwfn63GaHuQ43QyvHwaq2xLxw1xPM5+kY8QlsourX5RByzJa00P6oLqpa4bHFSWKYoPr8UwHnSrDgC7PNFwxV9RKCmwRrjvEXoCDsRdEuZWHg2Vv4ZlehGR5+NbGlC9uARG2rWtq98YtJkV5z11NOvQYZF38M0IhY16vwQwsVXLeWYHMWDtmiCI2lCVoCPWwwUtu1kBSup/SdIhnhMXrvr5y+bQcMZ/T0zPFTWSTrTwLndIdTowlPsIczvRBs2lFB1j", + }, + }, + }, + }, + }, + }, + SingleSignOnServices: []types.SingleSignOnService{ + { + Location: "http://expected.login/", + }, + }, + }, + } + data, _ := xml.Marshal(meta) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/metadata" { + s.T().Errorf("Unexpected Request. Expected /metadata") + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + })) + + cfg := samlConfig.IdentityProvider{ + MetadataUrl: fmt.Sprintf("%s/metadata", server.URL), + } + + // when + fetchedMeta, err := fetchIdpMetadata(cfg) + s.Require().NoError(err) + + s.Assert().Equal(meta.IDPSSODescriptor.SingleSignOnServices[0].Location, fetchedMeta.SingleSignOnUrl) + s.Assert().Equal(meta.EntityID, fetchedMeta.Issuer) + s.Assert().Equal(1, len(fetchedMeta.certs.Roots)) +} + +func (s *providerSuite) TestProvider_FAIL_Fetch_IDP_Metadata_With_Non_Xml() { + // given + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/metadata" { + s.T().Errorf("Unexpected Request. Expected /metadata") + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{ \"iam\": \"JSON\"}")) + })) + + cfg := samlConfig.IdentityProvider{ + MetadataUrl: fmt.Sprintf("%s/metadata", server.URL), + } + + // when + _, err := fetchIdpMetadata(cfg) + s.Assert().ErrorContains(err, "unable to unmarshal idp metadata response") +} + +func (s *providerSuite) TestProvider_FAIL_Fetch_IDP_Metadata_With_Wrong_Status_Code() { + // given + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/metadata" { + s.T().Errorf("Unexpected Request. Expected /metadata") + } + + w.WriteHeader(http.StatusNoContent) + _, _ = w.Write(nil) + })) + + cfg := samlConfig.IdentityProvider{ + MetadataUrl: fmt.Sprintf("%s/metadata", server.URL), + } + + // when + _, err := fetchIdpMetadata(cfg) + s.Assert().ErrorContains(err, "request for idp metadata failed with status code") +} + +func (s *providerSuite) TestProvider_Fail_Fetch_IDP_Metadata_No_Parsable_Cert() { + // given + meta := &types.EntityDescriptor{ + EntityID: "Lorem", + IDPSSODescriptor: &types.IDPSSODescriptor{ + KeyDescriptors: []types.KeyDescriptor{ + { + KeyInfo: dsigTypes.KeyInfo{ + X509Data: dsigTypes.X509Data{ + X509Certificates: []dsigTypes.X509Certificate{ + { + Data: "DATA", + }, + }, + }, + }, + }, + }, + }, + } + data, _ := xml.Marshal(meta) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/metadata" { + s.T().Errorf("Unexpected Request. Expected /metadata") + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + })) + + cfg := samlConfig.IdentityProvider{ + MetadataUrl: fmt.Sprintf("%s/metadata", server.URL), + } + + // when + _, err := fetchIdpMetadata(cfg) + s.Assert().Error(err) + + s.Assert().ErrorContains(err, "malformed certificate") +} + +func (s *providerSuite) TestProvider_Parse_Certificate() { + // given + cert := dsigTypes.X509Certificate{ + Data: "MIIDCDCCAfCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAnMSUwIwYDVQQDExxIYW5rbyBBdXRoZW50aWNhdGlvbiBTZXJ2aWNlMB4XDTI0MDIxMjA5MjE1OFoXDTI1MDIxMTA5MjE1OFowJzElMCMGA1UEAxMcSGFua28gQXV0aGVudGljYXRpb24gU2VydmljZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKFFKmsZSTgOvFiVySkkkNYvuXb7cnv2d74uBPQzCJFU6RY7fRAFOPRmkZ+ICFIOW25/k8S6bn1igcPyAHhMMAuVNO/S1uTAM+A+lkqkyKsjt1L5qrMYbqLXhBd1hMgsEi0jIzGzXFtX4h2B4dd5CtZd0oUHTxWC1Sv7bq0vt5CqcSRaGWN83HHkRySZ+tjtvfTNemzLqvmoQrDBWukL0XJnOs/sbw55sq2oNORQKpwinjGcNoJfrvEgDVXVDrSixHtx5RX03QRn3N7o+dhCDIcp7yHx5n2GEcqLrCY9lniwLZZZ5IgWSOAwZK9N8WLlg6RJad1eIZ8ovdPYSuDMY70CAwEAAaM/MD0wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCFU4elOcts4yngWHzolom3t2VC4IQjFwoh59qyXy0cYRgfLrclKdpxgPO556iG/G/UfTbH1sD9cZlmMAIMzFwfn63GaHuQ43QyvHwaq2xLxw1xPM5+kY8QlsourX5RByzJa00P6oLqpa4bHFSWKYoPr8UwHnSrDgC7PNFwxV9RKCmwRrjvEXoCDsRdEuZWHg2Vv4ZlehGR5+NbGlC9uARG2rWtq98YtJkV5z11NOvQYZF38M0IhY16vwQwsVXLeWYHMWDtmiCI2lCVoCPWwwUtu1kBSup/SdIhnhMXrvr5y+bQcMZ/T0zPFTWSTrTwLndIdTowlPsIczvRBs2lFB1j", + } + + // when + parsedCert, err := parseCertificate(0, cert) + s.Require().NoError(err) + + s.Assert().NotNil(parsedCert) + s.Equal("Hanko Authentication Service", parsedCert.Issuer.CommonName) +} + +func (s *providerSuite) TestProvider_Fail_Parse_Empty_Certificate() { + // given + cert := dsigTypes.X509Certificate{ + Data: "", + } + + // when + _, err := parseCertificate(0, cert) + s.Assert().Error(err) + s.Assert().ErrorContains(err, "metadata contains an empty certificate at index") +} + +func (s *providerSuite) TestProvider_Fail_Parse_NoB64_Certificate() { + // given + cert := dsigTypes.X509Certificate{ + Data: "----", + } + + // when + _, err := parseCertificate(0, cert) + s.Assert().Error(err) + s.Assert().ErrorContains(err, "unable to decode certificate at index") +} + +func (s *providerSuite) TestProvider_Fail_Parse_Wrong_Certificate() { + // given + cert := dsigTypes.X509Certificate{ + Data: "DIIDCDCCAfCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAnMSUwIwYDVQQDExxIYW5rbyBBdXRoZW50aWNhdGlvbiBTZXJ2aWNlMB4XDTI0MDIxMjA5MjE1OFoXDTI1MDIxMTA5MjE1OFowJzElMCMGA1UEAxMcSGFua28gQXV0aGVudGljYXRpb24gU2VydmljZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKFFKmsZSTgOvFiVySkkkNYvuXb7cnv2d74uBPQzCJFU6RY7fRAFOPRmkZ+ICFIOW25/k8S6bn1igcPyAHhMMAuVNO/S1uTAM+A+lkqkyKsjt1L5qrMYbqLXhBd1hMgsEi0jIzGzXFtX4h2B4dd5CtZd0oUHTxWC1Sv7bq0vt5CqcSRaGWN83HHkRySZ+tjtvfTNemzLqvmoQrDBWukL0XJnOs/sbw55sq2oNORQKpwinjGcNoJfrvEgDVXVDrSixHtx5RX03QRn3N7o+dhCDIcp7yHx5n2GEcqLrCY9lniwLZZZ5IgWSOAwZK9N8WLlg6RJad1eIZ8ovdPYSuDMY70CAwEAAaM/MD0wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCFU4elOcts4yngWHzolom3t2VC4IQjFwoh59qyXy0cYRgfLrclKdpxgPO556iG/G/UfTbH1sD9cZlmMAIMzFwfn63GaHuQ43QyvHwaq2xLxw1xPM5+kY8QlsourX5RByzJa00P6oLqpa4bHFSWKYoPr8UwHnSrDgC7PNFwxV9RKCmwRrjvEXoCDsRdEuZWHg2Vv4ZlehGR5+NbGlC9uARG2rWtq98YtJkV5z11NOvQYZF38M0IhY16vwQwsVXLeWYHMWDtmiCI2lCVoCPWwwUtu1kBSup/SdIhnhMXrvr5y+bQcMZ/T0zPFTWSTrTwLndIdTowlPsIczvRBs2lFB1j", + } + + // when + _, err := parseCertificate(0, cert) + s.Assert().Error(err) + s.Assert().ErrorContains(err, "unable to parse certificate at index") +} + +func (s *providerSuite) TestProvider_GetProvider() { + // given + meta := &types.EntityDescriptor{ + EntityID: "Lorem", + IDPSSODescriptor: &types.IDPSSODescriptor{ + KeyDescriptors: []types.KeyDescriptor{ + { + KeyInfo: dsigTypes.KeyInfo{ + X509Data: dsigTypes.X509Data{ + X509Certificates: []dsigTypes.X509Certificate{ + { + Data: "MIIDCDCCAfCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAnMSUwIwYDVQQDExxIYW5rbyBBdXRoZW50aWNhdGlvbiBTZXJ2aWNlMB4XDTI0MDIxMjA5MjE1OFoXDTI1MDIxMTA5MjE1OFowJzElMCMGA1UEAxMcSGFua28gQXV0aGVudGljYXRpb24gU2VydmljZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKFFKmsZSTgOvFiVySkkkNYvuXb7cnv2d74uBPQzCJFU6RY7fRAFOPRmkZ+ICFIOW25/k8S6bn1igcPyAHhMMAuVNO/S1uTAM+A+lkqkyKsjt1L5qrMYbqLXhBd1hMgsEi0jIzGzXFtX4h2B4dd5CtZd0oUHTxWC1Sv7bq0vt5CqcSRaGWN83HHkRySZ+tjtvfTNemzLqvmoQrDBWukL0XJnOs/sbw55sq2oNORQKpwinjGcNoJfrvEgDVXVDrSixHtx5RX03QRn3N7o+dhCDIcp7yHx5n2GEcqLrCY9lniwLZZZ5IgWSOAwZK9N8WLlg6RJad1eIZ8ovdPYSuDMY70CAwEAAaM/MD0wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCFU4elOcts4yngWHzolom3t2VC4IQjFwoh59qyXy0cYRgfLrclKdpxgPO556iG/G/UfTbH1sD9cZlmMAIMzFwfn63GaHuQ43QyvHwaq2xLxw1xPM5+kY8QlsourX5RByzJa00P6oLqpa4bHFSWKYoPr8UwHnSrDgC7PNFwxV9RKCmwRrjvEXoCDsRdEuZWHg2Vv4ZlehGR5+NbGlC9uARG2rWtq98YtJkV5z11NOvQYZF38M0IhY16vwQwsVXLeWYHMWDtmiCI2lCVoCPWwwUtu1kBSup/SdIhnhMXrvr5y+bQcMZ/T0zPFTWSTrTwLndIdTowlPsIczvRBs2lFB1j", + }, + }, + }, + }, + }, + }, + SingleSignOnServices: []types.SingleSignOnService{ + { + Location: "http://expected.login/", + }, + }, + }, + } + data, _ := xml.Marshal(meta) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/metadata" { + s.T().Errorf("Unexpected Request. Expected /metadata") + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + })) + + providerName := "lorem" + cfg := config.DefaultConfig() + samlCfg := samlConfig.IdentityProvider{ + Enabled: true, + Name: "Test Provider", + Domain: "hanko.io", + MetadataUrl: fmt.Sprintf("%s/metadata", server.URL), + SkipEmailVerification: false, + AttributeMap: samlConfig.AttributeMap{}, + } + + // when + provider, err := GetProvider(providerName, cfg, samlCfg, s.Storage.GetSamlCertificatePersister()) + s.Require().NoError(err) + + s.Assert().Equal("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", provider.GetConfig().AttributeMap.Name) + s.Assert().Equal("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", provider.GetConfig().AttributeMap.GivenName) + s.Assert().Equal("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", provider.GetConfig().AttributeMap.FamilyName) + s.Assert().Equal("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", provider.GetConfig().AttributeMap.Email) +} + +func (s *providerSuite) TestProvider_GetProvider_Auth0() { + // given + meta := &types.EntityDescriptor{ + EntityID: "Lorem", + IDPSSODescriptor: &types.IDPSSODescriptor{ + KeyDescriptors: []types.KeyDescriptor{ + { + KeyInfo: dsigTypes.KeyInfo{ + X509Data: dsigTypes.X509Data{ + X509Certificates: []dsigTypes.X509Certificate{ + { + Data: "MIIDCDCCAfCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAnMSUwIwYDVQQDExxIYW5rbyBBdXRoZW50aWNhdGlvbiBTZXJ2aWNlMB4XDTI0MDIxMjA5MjE1OFoXDTI1MDIxMTA5MjE1OFowJzElMCMGA1UEAxMcSGFua28gQXV0aGVudGljYXRpb24gU2VydmljZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKFFKmsZSTgOvFiVySkkkNYvuXb7cnv2d74uBPQzCJFU6RY7fRAFOPRmkZ+ICFIOW25/k8S6bn1igcPyAHhMMAuVNO/S1uTAM+A+lkqkyKsjt1L5qrMYbqLXhBd1hMgsEi0jIzGzXFtX4h2B4dd5CtZd0oUHTxWC1Sv7bq0vt5CqcSRaGWN83HHkRySZ+tjtvfTNemzLqvmoQrDBWukL0XJnOs/sbw55sq2oNORQKpwinjGcNoJfrvEgDVXVDrSixHtx5RX03QRn3N7o+dhCDIcp7yHx5n2GEcqLrCY9lniwLZZZ5IgWSOAwZK9N8WLlg6RJad1eIZ8ovdPYSuDMY70CAwEAAaM/MD0wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCFU4elOcts4yngWHzolom3t2VC4IQjFwoh59qyXy0cYRgfLrclKdpxgPO556iG/G/UfTbH1sD9cZlmMAIMzFwfn63GaHuQ43QyvHwaq2xLxw1xPM5+kY8QlsourX5RByzJa00P6oLqpa4bHFSWKYoPr8UwHnSrDgC7PNFwxV9RKCmwRrjvEXoCDsRdEuZWHg2Vv4ZlehGR5+NbGlC9uARG2rWtq98YtJkV5z11NOvQYZF38M0IhY16vwQwsVXLeWYHMWDtmiCI2lCVoCPWwwUtu1kBSup/SdIhnhMXrvr5y+bQcMZ/T0zPFTWSTrTwLndIdTowlPsIczvRBs2lFB1j", + }, + }, + }, + }, + }, + }, + SingleSignOnServices: []types.SingleSignOnService{ + { + Location: "http://expected.login/", + }, + }, + }, + } + data, _ := xml.Marshal(meta) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/metadata" { + s.T().Errorf("Unexpected Request. Expected /metadata") + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + })) + + providerName := "auth0" + cfg := config.DefaultConfig() + samlCfg := samlConfig.IdentityProvider{ + Enabled: true, + Name: "Test Provider", + Domain: "hanko.io", + MetadataUrl: fmt.Sprintf("%s/metadata", server.URL), + SkipEmailVerification: false, + AttributeMap: samlConfig.AttributeMap{ + Name: "", + }, + } + + // when + provider, err := GetProvider(providerName, cfg, samlCfg, s.Storage.GetSamlCertificatePersister()) + s.Require().NoError(err) + + s.Assert().Equal("http://schemas.auth0.com/name", provider.GetConfig().AttributeMap.Name) + s.Assert().Equal("http://schemas.auth0.com/email", provider.GetConfig().AttributeMap.Email) + s.Assert().Equal("http://schemas.auth0.com/email_verified", provider.GetConfig().AttributeMap.EmailVerified) + s.Assert().Equal("http://schemas.auth0.com/nickname", provider.GetConfig().AttributeMap.NickName) + s.Assert().Equal("http://schemas.auth0.com/picture", provider.GetConfig().AttributeMap.Picture) + s.Assert().Equal("http://schemas.auth0.com/updated_at", provider.GetConfig().AttributeMap.UpdatedAt) +} diff --git a/backend/ee/saml/provider/saml.go b/backend/ee/saml/provider/saml.go index 2cbc5dcf4..30e4d7034 100644 --- a/backend/ee/saml/provider/saml.go +++ b/backend/ee/saml/provider/saml.go @@ -18,7 +18,7 @@ type BaseSamlProvider struct { Service saml2.SAMLServiceProvider } -func NewBaseSamlProvider(cfg *config.Config, idpConfig samlConfig.IdentityProvider, persister persistence.SamlCertificatePersister) (ServiceProvider, error) { +func NewBaseSamlProvider(cfg *config.Config, idpConfig samlConfig.IdentityProvider, persister persistence.SamlCertificatePersister, useDefaults bool) (ServiceProvider, error) { serviceProviderCertStore, err := loadCertificate(cfg, persister) if err != nil { return nil, err @@ -50,7 +50,10 @@ func NewBaseSamlProvider(cfg *config.Config, idpConfig samlConfig.IdentityProvid AllowMissingAttributes: cfg.Saml.Options.AllowMissingAttributes, }, } - provider.UseDefaultAttributesIfEmpty() + + if useDefaults { + provider.UseDefaultAttributesIfEmpty() + } return provider, nil } diff --git a/backend/ee/saml/provider/saml_test.go b/backend/ee/saml/provider/saml_test.go new file mode 100644 index 000000000..c428e222c --- /dev/null +++ b/backend/ee/saml/provider/saml_test.go @@ -0,0 +1,397 @@ +package provider + +import ( + "encoding/xml" + "fmt" + "github.com/gofrs/uuid" + saml2 "github.com/russellhaering/gosaml2" + "github.com/russellhaering/gosaml2/types" + dsigTypes "github.com/russellhaering/goxmldsig/types" + "github.com/stretchr/testify/suite" + "github.com/teamhanko/hanko/backend/config" + samlConfig "github.com/teamhanko/hanko/backend/ee/saml/config" + "github.com/teamhanko/hanko/backend/persistence/models" + "github.com/teamhanko/hanko/backend/test" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestBaseSamlProviderSuite(t *testing.T) { + testSuite := new(baseSamlProviderSuite) + setupMetadataServer(testSuite) + + t.Parallel() + suite.Run(t, testSuite) +} + +type baseSamlProviderSuite struct { + test.Suite + server *httptest.Server +} + +func setupMetadataServer(s *baseSamlProviderSuite) { + meta := &types.EntityDescriptor{ + EntityID: "Lorem", + IDPSSODescriptor: &types.IDPSSODescriptor{ + KeyDescriptors: []types.KeyDescriptor{ + { + KeyInfo: dsigTypes.KeyInfo{ + X509Data: dsigTypes.X509Data{ + X509Certificates: []dsigTypes.X509Certificate{ + { + Data: "MIIDCDCCAfCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAnMSUwIwYDVQQDExxIYW5rbyBBdXRoZW50aWNhdGlvbiBTZXJ2aWNlMB4XDTI0MDIxMjA5MjE1OFoXDTI1MDIxMTA5MjE1OFowJzElMCMGA1UEAxMcSGFua28gQXV0aGVudGljYXRpb24gU2VydmljZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKFFKmsZSTgOvFiVySkkkNYvuXb7cnv2d74uBPQzCJFU6RY7fRAFOPRmkZ+ICFIOW25/k8S6bn1igcPyAHhMMAuVNO/S1uTAM+A+lkqkyKsjt1L5qrMYbqLXhBd1hMgsEi0jIzGzXFtX4h2B4dd5CtZd0oUHTxWC1Sv7bq0vt5CqcSRaGWN83HHkRySZ+tjtvfTNemzLqvmoQrDBWukL0XJnOs/sbw55sq2oNORQKpwinjGcNoJfrvEgDVXVDrSixHtx5RX03QRn3N7o+dhCDIcp7yHx5n2GEcqLrCY9lniwLZZZ5IgWSOAwZK9N8WLlg6RJad1eIZ8ovdPYSuDMY70CAwEAAaM/MD0wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCFU4elOcts4yngWHzolom3t2VC4IQjFwoh59qyXy0cYRgfLrclKdpxgPO556iG/G/UfTbH1sD9cZlmMAIMzFwfn63GaHuQ43QyvHwaq2xLxw1xPM5+kY8QlsourX5RByzJa00P6oLqpa4bHFSWKYoPr8UwHnSrDgC7PNFwxV9RKCmwRrjvEXoCDsRdEuZWHg2Vv4ZlehGR5+NbGlC9uARG2rWtq98YtJkV5z11NOvQYZF38M0IhY16vwQwsVXLeWYHMWDtmiCI2lCVoCPWwwUtu1kBSup/SdIhnhMXrvr5y+bQcMZ/T0zPFTWSTrTwLndIdTowlPsIczvRBs2lFB1j", + }, + }, + }, + }, + }, + }, + SingleSignOnServices: []types.SingleSignOnService{ + { + Location: "http://expected.login/", + }, + }, + }, + } + data, _ := xml.Marshal(meta) + + s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/metadata" { + s.T().Errorf("Unexpected Request. Expected /metadata") + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + })) +} + +func (s *baseSamlProviderSuite) TestBaseSamlProvider_Create() { + // given + cfg := config.DefaultConfig() + samlCfg := samlConfig.IdentityProvider{ + Enabled: true, + Name: "Test Provider", + Domain: "hanko.io", + MetadataUrl: fmt.Sprintf("%s/metadata", s.server.URL), + SkipEmailVerification: false, + AttributeMap: samlConfig.AttributeMap{ + Name: "", + }, + } + + provider, err := NewBaseSamlProvider(cfg, samlCfg, s.Storage.GetSamlCertificatePersister(), true) + s.Require().NoError(err) + + s.Assert().NotNil(provider) + s.Assert().Equal("Lorem", provider.GetService().IdentityProviderIssuer) + s.Assert().Equal("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", provider.GetConfig().AttributeMap.Name) + s.Assert().Equal("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", provider.GetConfig().AttributeMap.GivenName) + s.Assert().Equal("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", provider.GetConfig().AttributeMap.FamilyName) + s.Assert().Equal("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", provider.GetConfig().AttributeMap.Email) +} + +func (s *baseSamlProviderSuite) TestBaseSamlProvider_CreateWithoutDefaults() { + // given + cfg := config.DefaultConfig() + samlCfg := samlConfig.IdentityProvider{ + Enabled: true, + Name: "Test Provider", + Domain: "hanko.io", + MetadataUrl: fmt.Sprintf("%s/metadata", s.server.URL), + SkipEmailVerification: false, + AttributeMap: samlConfig.AttributeMap{ + Name: "", + }, + } + + provider, err := NewBaseSamlProvider(cfg, samlCfg, s.Storage.GetSamlCertificatePersister(), false) + s.Require().NoError(err) + + s.Assert().NotNil(provider) + s.Assert().Equal("Lorem", provider.GetService().IdentityProviderIssuer) + s.Assert().Equal("", provider.GetConfig().AttributeMap.Name) + s.Assert().Equal("", provider.GetConfig().AttributeMap.GivenName) + s.Assert().Equal("", provider.GetConfig().AttributeMap.FamilyName) + s.Assert().Equal("", provider.GetConfig().AttributeMap.Email) +} + +func (s *baseSamlProviderSuite) TestBaseSamlProvider_Fail_CreateOnMetadataError() { + // given + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/metadata" { + s.T().Errorf("Unexpected Request. Expected /metadata") + } + + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte{}) + })) + + cfg := config.DefaultConfig() + samlCfg := samlConfig.IdentityProvider{ + Enabled: true, + Name: "Test Provider", + Domain: "hanko.io", + MetadataUrl: fmt.Sprintf("%s/metadata", server.URL), + SkipEmailVerification: false, + AttributeMap: samlConfig.AttributeMap{ + Name: "", + }, + } + + _, err := NewBaseSamlProvider(cfg, samlCfg, s.Storage.GetSamlCertificatePersister(), false) + s.Assert().Error(err) +} + +func (s *baseSamlProviderSuite) TestBaseSamlProvider_Fail_CreateOnCertificateError() { + if testing.Short() { + s.T().Skip("skipping test in short mode") + } + + // given + certId, err := uuid.NewV4() + s.Require().NoError(err) + + now := time.Now() + + err = s.Storage.GetSamlCertificatePersister().Create(&models.SamlCertificate{ + ID: certId, + CertData: "-", + CertKey: "--------------------------------", + EncryptionKey: "--------------------------------", + CreatedAt: now, + UpdatedAt: now, + }) + s.Require().NoError(err) + + cfg := config.DefaultConfig() + samlCfg := samlConfig.IdentityProvider{ + Enabled: true, + Name: "Test Provider", + Domain: "hanko.io", + MetadataUrl: fmt.Sprintf("%s/metadata", s.server.URL), + SkipEmailVerification: false, + AttributeMap: samlConfig.AttributeMap{ + Name: "", + }, + } + + _, err = NewBaseSamlProvider(cfg, samlCfg, s.Storage.GetSamlCertificatePersister(), false) + s.Assert().Error(err) +} + +func (s *baseSamlProviderSuite) TestBaseSamlProvider_ProvideMetadataAsXml() { + // given + cfg := config.DefaultConfig() + samlCfg := samlConfig.IdentityProvider{ + Enabled: true, + Name: "Test Provider", + Domain: "hanko.io", + MetadataUrl: fmt.Sprintf("%s/metadata", s.server.URL), + SkipEmailVerification: false, + AttributeMap: samlConfig.AttributeMap{ + Name: "", + }, + } + + provider, err := NewBaseSamlProvider(cfg, samlCfg, s.Storage.GetSamlCertificatePersister(), false) + s.Require().NoError(err) + + metadata, err := provider.ProvideMetadataAsXml() + s.Require().NoError(err) + + s.Assert().NotEmpty(metadata) +} + +func createTestAssertion(expectedValues saml2.Values, expectedExpireDate time.Time, expectedCreationTiome time.Time) saml2.AssertionInfo { + return saml2.AssertionInfo{ + Values: expectedValues, + Assertions: []types.Assertion{ + { + Issuer: &types.Issuer{ + Value: "Lorem", + }, + Subject: &types.Subject{ + NameID: &types.NameID{ + Value: "Hanko", + }, + }, + Conditions: &types.Conditions{ + NotOnOrAfter: expectedExpireDate.String(), + }, + }, + }, + AuthnInstant: &expectedCreationTiome, + } +} + +func (s *baseSamlProviderSuite) TestBaseSamlProvider_GetUserData() { + // given + cfg := config.DefaultConfig() + samlCfg := samlConfig.IdentityProvider{ + Enabled: true, + Name: "Test Provider", + Domain: "hanko.io", + MetadataUrl: fmt.Sprintf("%s/metadata", s.server.URL), + SkipEmailVerification: false, + AttributeMap: samlConfig.AttributeMap{ + Name: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", + EmailVerified: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailverified", + }, + } + + provider, err := NewBaseSamlProvider(cfg, samlCfg, s.Storage.GetSamlCertificatePersister(), false) + s.Require().NoError(err) + + testValues := make(saml2.Values) + testValues["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"] = types.Attribute{ + Values: []types.AttributeValue{ + { + Value: "Ipsum", + }, + }, + } + testValues["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email"] = types.Attribute{ + Values: []types.AttributeValue{ + { + Value: "dev@hanko.io", + }, + }, + } + testValues["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailverified"] = types.Attribute{ + Values: []types.AttributeValue{ + { + Value: "true", + }, + }, + } + + testCreateTime := time.Now() + testExpireTime := testCreateTime.Add(time.Hour * 24 * 7) + testAssertion := createTestAssertion(testValues, testCreateTime, testExpireTime) + + // when + userdata := provider.GetUserData(&testAssertion) + + // then + s.Assert().Equal(testAssertion.Assertions[0].Issuer.Value, userdata.Metadata.Issuer) + s.Assert().Equal(testAssertion.Assertions[0].Subject.NameID.Value, userdata.Metadata.Subject) + s.Assert().Equal(testAssertion.Values.Get("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"), userdata.Metadata.Name) + s.Assert().Equal(testAssertion.Values.Get("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"), userdata.Metadata.Email) + s.Assert().True(userdata.Metadata.EmailVerified) + s.Assert().True(userdata.Emails[0].Verified) +} + +func (s *baseSamlProviderSuite) TestBaseSamlProvider_GetUserData_WithoutVerification() { + // given + cfg := config.DefaultConfig() + samlCfg := samlConfig.IdentityProvider{ + Enabled: true, + Name: "Test Provider", + Domain: "hanko.io", + MetadataUrl: fmt.Sprintf("%s/metadata", s.server.URL), + SkipEmailVerification: false, + AttributeMap: samlConfig.AttributeMap{ + Name: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", + EmailVerified: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailverified", + }, + } + + provider, err := NewBaseSamlProvider(cfg, samlCfg, s.Storage.GetSamlCertificatePersister(), false) + s.Require().NoError(err) + + testValues := make(saml2.Values) + testValues["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"] = types.Attribute{ + Values: []types.AttributeValue{ + { + Value: "Ipsum", + }, + }, + } + testValues["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email"] = types.Attribute{ + Values: []types.AttributeValue{ + { + Value: "dev@hanko.io", + }, + }, + } + + testCreateTime := time.Now() + testExpireTime := testCreateTime.Add(time.Hour * 24 * 7) + testAssertion := createTestAssertion(testValues, testCreateTime, testExpireTime) + + // when + userdata := provider.GetUserData(&testAssertion) + + // then + s.Assert().False(userdata.Metadata.EmailVerified) + s.Assert().False(userdata.Emails[0].Verified) +} + +func (s *baseSamlProviderSuite) TestBaseSamlProvider_GetUserData_WithSkipVerification() { + // given + cfg := config.DefaultConfig() + samlCfg := samlConfig.IdentityProvider{ + Enabled: true, + Name: "Test Provider", + Domain: "hanko.io", + MetadataUrl: fmt.Sprintf("%s/metadata", s.server.URL), + SkipEmailVerification: true, + AttributeMap: samlConfig.AttributeMap{ + Name: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", + EmailVerified: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailverified", + }, + } + + provider, err := NewBaseSamlProvider(cfg, samlCfg, s.Storage.GetSamlCertificatePersister(), false) + s.Require().NoError(err) + + testValues := make(saml2.Values) + testValues["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"] = types.Attribute{ + Values: []types.AttributeValue{ + { + Value: "Ipsum", + }, + }, + } + testValues["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email"] = types.Attribute{ + Values: []types.AttributeValue{ + { + Value: "dev@hanko.io", + }, + }, + } + + testCreateTime := time.Now() + testExpireTime := testCreateTime.Add(time.Hour * 24 * 7) + testAssertion := createTestAssertion(testValues, testCreateTime, testExpireTime) + + // when + userdata := provider.GetUserData(&testAssertion) + + // then + s.Assert().True(userdata.Metadata.EmailVerified) + s.Assert().False(userdata.Emails[0].Verified) +} + +func (s *baseSamlProviderSuite) TestBaseSamlProvider_GetDomain() { + // given + cfg := config.DefaultConfig() + samlCfg := samlConfig.IdentityProvider{ + Enabled: true, + Name: "Test Provider", + Domain: "hanko.io", + MetadataUrl: fmt.Sprintf("%s/metadata", s.server.URL), + SkipEmailVerification: true, + AttributeMap: samlConfig.AttributeMap{ + Name: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", + EmailVerified: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailverified", + }, + } + + provider, err := NewBaseSamlProvider(cfg, samlCfg, s.Storage.GetSamlCertificatePersister(), false) + s.Require().NoError(err) + + s.Assert().Equal(samlCfg.Domain, provider.GetDomain()) +} diff --git a/backend/ee/saml/router.go b/backend/ee/saml/router.go index 7f45ccacc..8bdd18326 100644 --- a/backend/ee/saml/router.go +++ b/backend/ee/saml/router.go @@ -4,6 +4,7 @@ import ( "github.com/labstack/echo/v4" auditlog "github.com/teamhanko/hanko/backend/audit_log" "github.com/teamhanko/hanko/backend/config" + "github.com/teamhanko/hanko/backend/ee/saml/admin" "github.com/teamhanko/hanko/backend/persistence" "github.com/teamhanko/hanko/backend/session" ) @@ -16,3 +17,16 @@ func CreateSamlRoutes(e *echo.Echo, cfg *config.Config, persister persistence.Pe routingGroup.GET("/auth", handler.Auth) routingGroup.POST("/callback", handler.CallbackPost) } + +func CreateSamlAdminRoutes(e *echo.Echo, cfg *config.Config, persister persistence.Persister) { + handler := admin.NewSamlAdminHandler(cfg, persister) + + routingGroup := e.Group("saml") + routingGroup.GET("", handler.List) + routingGroup.POST("", handler.Create) + + singleProviderGroup := routingGroup.Group("/:id") + singleProviderGroup.GET("", handler.Get) + singleProviderGroup.PUT("", handler.Update) + singleProviderGroup.DELETE("", handler.Delete) +} diff --git a/backend/ee/saml/utils/url_test.go b/backend/ee/saml/utils/url_test.go new file mode 100644 index 000000000..9e3c616df --- /dev/null +++ b/backend/ee/saml/utils/url_test.go @@ -0,0 +1,69 @@ +package utils + +import ( + "fmt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/teamhanko/hanko/backend/ee/saml/config" + "testing" +) + +func TestUriUtils_IsAllowedRedirect(t *testing.T) { + // given + domain := "https://mateki.de" + cfg := config.Saml{ + AllowedRedirectURLS: []string{ + domain, + }, + } + err := cfg.PostProcess() + require.NoError(t, err) + + isAllowed := IsAllowedRedirect(cfg, domain) + assert.True(t, isAllowed) +} + +func TestUriUtils_IsAllowedRedirect_With_Slash(t *testing.T) { + // given + domain := "https://mateki.de" + cfg := config.Saml{ + AllowedRedirectURLS: []string{ + domain, + }, + } + err := cfg.PostProcess() + require.NoError(t, err) + + isAllowed := IsAllowedRedirect(cfg, fmt.Sprintf("%s/", domain)) + assert.True(t, isAllowed) +} + +func TestUriUtils_EmptyRedirectIsNotAllowed(t *testing.T) { + // given + domain := "https://mateki.de" + cfg := config.Saml{ + AllowedRedirectURLS: []string{ + domain, + }, + } + err := cfg.PostProcess() + require.NoError(t, err) + + isAllowed := IsAllowedRedirect(cfg, "") + assert.False(t, isAllowed) +} + +func TestUriUtils_WrongRedirectIsNotAllowed(t *testing.T) { + // given + domain := "https://mateki.de" + cfg := config.Saml{ + AllowedRedirectURLS: []string{ + domain, + }, + } + err := cfg.PostProcess() + require.NoError(t, err) + + isAllowed := IsAllowedRedirect(cfg, "http://localhost") + assert.False(t, isAllowed) +} diff --git a/backend/handler/admin_router.go b/backend/handler/admin_router.go index e4116a62e..b6499929f 100644 --- a/backend/handler/admin_router.go +++ b/backend/handler/admin_router.go @@ -8,6 +8,7 @@ import ( "github.com/teamhanko/hanko/backend/config" "github.com/teamhanko/hanko/backend/crypto/jwk" "github.com/teamhanko/hanko/backend/dto" + "github.com/teamhanko/hanko/backend/ee/saml" hankoMiddleware "github.com/teamhanko/hanko/backend/middleware" "github.com/teamhanko/hanko/backend/persistence" "github.com/teamhanko/hanko/backend/template" @@ -80,5 +81,7 @@ func NewAdminRouter(cfg *config.Config, persister persistence.Persister, prometh webhooks.DELETE("/:id", webhookHandler.Delete) webhooks.PUT("/:id", webhookHandler.Update) + saml.CreateSamlAdminRoutes(e, cfg, persister) + return e } diff --git a/backend/persistence/migrations/20240130120832_create_saml_identity_providers.down.fizz b/backend/persistence/migrations/20240130120832_create_saml_identity_providers.down.fizz new file mode 100644 index 000000000..20eb48904 --- /dev/null +++ b/backend/persistence/migrations/20240130120832_create_saml_identity_providers.down.fizz @@ -0,0 +1 @@ +drop_table("saml_identity_providers") diff --git a/backend/persistence/migrations/20240130120832_create_saml_identity_providers.up.fizz b/backend/persistence/migrations/20240130120832_create_saml_identity_providers.up.fizz new file mode 100644 index 000000000..df3289778 --- /dev/null +++ b/backend/persistence/migrations/20240130120832_create_saml_identity_providers.up.fizz @@ -0,0 +1,10 @@ +create_table("saml_identity_providers") { + t.Column("id", "uuid", {primary: true}) + t.Column("enabled", "bool", {}) + t.Column("name", "string", {}) + t.Column("domain", "string", { unique: true }) + t.Column("metadata_url", "string", {}) + t.Column("skip_email_verification", "bool", {}) + + t.Timestamps() +} diff --git a/backend/persistence/migrations/20240130120923_create_saml_attribute_maps.down.fizz b/backend/persistence/migrations/20240130120923_create_saml_attribute_maps.down.fizz new file mode 100644 index 000000000..b2f0689c2 --- /dev/null +++ b/backend/persistence/migrations/20240130120923_create_saml_attribute_maps.down.fizz @@ -0,0 +1 @@ +drop_table("saml_attribute_maps") diff --git a/backend/persistence/migrations/20240130120923_create_saml_attribute_maps.up.fizz b/backend/persistence/migrations/20240130120923_create_saml_attribute_maps.up.fizz new file mode 100644 index 000000000..e3c4482db --- /dev/null +++ b/backend/persistence/migrations/20240130120923_create_saml_attribute_maps.up.fizz @@ -0,0 +1,26 @@ +create_table("saml_attribute_maps") { + t.Column("id", "uuid", {primary: true}) + t.Column("saml_identity_provider_id", "uuid", { unique: true }) + t.Column("name", "string", { "default": "" }) + t.Column("family_name", "string", { "default": "" }) + t.Column("given_name", "string", { "default": "" }) + t.Column("middle_name", "string", { "default": "" }) + t.Column("nickname", "string", { "default": "" }) + t.Column("preferred_username", "string", { "default": "" }) + t.Column("profile", "string", { "default": "" }) + t.Column("picture", "string", { "default": "" }) + t.Column("website", "string", { "default": "" }) + t.Column("gender", "string", { "default": "" }) + t.Column("birthdate", "string", { "default": "" }) + t.Column("zone_info", "string", { "default": "" }) + t.Column("locale", "string", { "default": "" }) + t.Column("saml_updated_at", "string", { "default": "" }) + t.Column("email", "string", { "default": "" }) + t.Column("email_verified", "string", { "default": "" }) + t.Column("phone", "string", { "default": "" }) + t.Column("phone_verified", "string", { "default": "" }) + + t.Timestamps() + + t.ForeignKey("saml_identity_provider_id", { "saml_identity_providers": ["id"]}, { "on_delete": "CASCADE", "on_update": "CASCADE" }) +} diff --git a/backend/persistence/models/saml_attribute_map.go b/backend/persistence/models/saml_attribute_map.go new file mode 100644 index 000000000..f1a52f336 --- /dev/null +++ b/backend/persistence/models/saml_attribute_map.go @@ -0,0 +1,45 @@ +package models + +import ( + "github.com/gobuffalo/validate/v3/validators" + "time" + + "github.com/gobuffalo/pop/v6" + "github.com/gobuffalo/validate/v3" + "github.com/gofrs/uuid" +) + +// SamlAttributeMap is used by pop to map your saml_attribute_maps database table to your go code. +type SamlAttributeMap struct { + ID uuid.UUID `json:"id" db:"id"` + IdentityProviderID uuid.UUID `json:"-" db:"saml_identity_provider_id"` + IdentityProvider *SamlIdentityProvider `json:"-" belongs_to:"saml_identity_provider"` + Name string `json:"name,omitempty" db:"name"` + FamilyName string `json:"family_name,omitempty" db:"family_name"` + GivenName string `json:"given_name,omitempty" db:"given_name"` + MiddleName string `json:"middle_name,omitempty" db:"middle_name"` + NickName string `json:"nickname,omitempty" db:"nickname"` + PreferredUsername string `json:"preferred_username,omitempty" db:"preferred_username"` + Profile string `json:"profile,omitempty" db:"profile"` + Picture string `json:"picture,omitempty" db:"picture"` + Website string `json:"website,omitempty" db:"website"` + Gender string `json:"gender,omitempty" db:"gender"` + Birthdate string `json:"birthdate,omitempty" db:"birthdate"` + ZoneInfo string `json:"zone_info,omitempty" db:"zone_info"` + Locale string `json:"locale,omitempty" db:"locale"` + SamlUpdatedAt string `json:"saml_updated_at,omitempty" db:"saml_updated_at"` + Email string `json:"email,omitempty" db:"email"` + EmailVerified string `json:"email_verified,omitempty" db:"email_verified"` + Phone string `json:"phone,omitempty" db:"phone"` + PhoneVerified string `json:"phone_verified,omitempty" db:"phone_verified"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. +// This method is not required and may be deleted. +func (s *SamlAttributeMap) Validate(tx *pop.Connection) (*validate.Errors, error) { + return validate.Validate( + &validators.UUIDIsPresent{Name: "ID", Field: s.ID}, + ), nil +} diff --git a/backend/persistence/models/saml_identity_provider.go b/backend/persistence/models/saml_identity_provider.go new file mode 100644 index 000000000..7fbd312fb --- /dev/null +++ b/backend/persistence/models/saml_identity_provider.go @@ -0,0 +1,39 @@ +package models + +import ( + "github.com/gobuffalo/validate/v3/validators" + "time" + + "github.com/gobuffalo/pop/v6" + "github.com/gobuffalo/validate/v3" + "github.com/gofrs/uuid" +) + +// SamlIdentityProvider is used by pop to map your saml_identity_providers database table to your go code. +type SamlIdentityProvider struct { + ID uuid.UUID `json:"id" db:"id"` + Enabled bool `json:"enabled" db:"enabled"` + Name string `json:"name" db:"name"` + Domain string `json:"domain" db:"domain"` + MetadataUrl string `json:"metadata_url" db:"metadata_url"` + SkipEmailVerification bool `json:"skip_email_verification" db:"skip_email_verification"` + AttributeMap SamlAttributeMap `json:"attribute_map" has_one:"saml_attribute_map"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// SamlIdentityProviders is not required by pop and may be deleted +type SamlIdentityProviders []SamlIdentityProvider + +// Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. +// This method is not required and may be deleted. +func (s *SamlIdentityProvider) Validate(_ *pop.Connection) (*validate.Errors, error) { + return validate.Validate( + &validators.UUIDIsPresent{Name: "ID", Field: s.ID}, + &validators.StringIsPresent{Name: "Name", Field: s.Name}, + &validators.StringIsPresent{Name: "Domain", Field: s.Domain}, + &validators.URLIsPresent{Name: "Metadata", Field: s.MetadataUrl}, + &validators.TimeIsPresent{Name: "UpdatedAt", Field: s.UpdatedAt}, + &validators.TimeIsPresent{Name: "CreatedAt", Field: s.CreatedAt}, + ), nil +} diff --git a/backend/persistence/persister.go b/backend/persistence/persister.go index 47e2e7f83..318ad358f 100644 --- a/backend/persistence/persister.go +++ b/backend/persistence/persister.go @@ -44,6 +44,7 @@ type Persister interface { GetSamlCertificatePersister() SamlCertificatePersister GetSamlCertificatePersisterWithConnection(tx *pop.Connection) SamlCertificatePersister GetWebhookPersister(tx *pop.Connection) WebhookPersister + GetSamlIdentityProviderPersister(tx *pop.Connection) SamlIdentityProviderPersister } type Migrator interface { @@ -233,3 +234,10 @@ func (p *persister) GetWebhookPersister(tx *pop.Connection) WebhookPersister { return NewWebhookPersister(p.DB) } + +func (p *persister) GetSamlIdentityProviderPersister(tx *pop.Connection) SamlIdentityProviderPersister { + if tx != nil { + return NewSamlIdentityProviderPersister(tx) + } + return NewSamlIdentityProviderPersister(p.DB) +} diff --git a/backend/persistence/saml_identity_provider_persister.go b/backend/persistence/saml_identity_provider_persister.go new file mode 100644 index 000000000..794c52088 --- /dev/null +++ b/backend/persistence/saml_identity_provider_persister.go @@ -0,0 +1,128 @@ +package persistence + +import ( + "database/sql" + "errors" + "fmt" + "github.com/gobuffalo/pop/v6" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type SamlIdentityProviderPersister interface { + List() (models.SamlIdentityProviders, error) + Get(providerId uuid.UUID) (*models.SamlIdentityProvider, error) + GetByDomain(domain string) (*models.SamlIdentityProvider, error) + + Create(provider *models.SamlIdentityProvider, attributeMap *models.SamlAttributeMap) error + + Update(provider *models.SamlIdentityProvider) error + Delete(provider *models.SamlIdentityProvider) error +} + +type samlIdentityProviderPersister struct { + db *pop.Connection +} + +func NewSamlIdentityProviderPersister(db *pop.Connection) SamlIdentityProviderPersister { + return &samlIdentityProviderPersister{db: db} +} + +func (s *samlIdentityProviderPersister) List() (models.SamlIdentityProviders, error) { + list := make(models.SamlIdentityProviders, 0) + err := s.db.Eager().All(&list) + + if err != nil && errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + + if err != nil { + return nil, err + } + + return list, nil +} + +func (s *samlIdentityProviderPersister) Get(providerId uuid.UUID) (*models.SamlIdentityProvider, error) { + var provider models.SamlIdentityProvider + err := s.db.Eager().Find(&provider, providerId) + + if err != nil && errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + + if err != nil { + return nil, err + } + + return &provider, nil +} + +func (s *samlIdentityProviderPersister) GetByDomain(domain string) (*models.SamlIdentityProvider, error) { + var provider models.SamlIdentityProvider + err := s.db.Eager().Where("domain = ?", domain).First(&provider) + + if err != nil && errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + + if err != nil { + return nil, err + } + + return &provider, nil +} + +func (s *samlIdentityProviderPersister) Create(provider *models.SamlIdentityProvider, attributeMap *models.SamlAttributeMap) error { + validationError, err := s.db.ValidateAndCreate(provider) + if err != nil { + return err + } + + if validationError != nil && validationError.HasAny() { + return fmt.Errorf("saml provider validation failed: %w", validationError) + } + + validationError, err = s.db.ValidateAndCreate(attributeMap) + if err != nil { + return err + } + + if validationError != nil && validationError.HasAny() { + return fmt.Errorf("saml provider attribute map validation failed: %w", validationError) + } + + return nil +} + +func (s *samlIdentityProviderPersister) Update(provider *models.SamlIdentityProvider) error { + validationError, err := s.db.ValidateAndUpdate(provider) + if err != nil { + return err + } + + if validationError != nil && validationError.HasAny() { + return fmt.Errorf("saml provider validation failed: %w", validationError) + } + + validationError, err = s.db.ValidateAndUpdate(&provider.AttributeMap) + if err != nil { + return err + } + + if validationError != nil && validationError.HasAny() { + return fmt.Errorf("saml provider attribute map validation failed: %w", validationError) + } + + return nil +} + +func (s *samlIdentityProviderPersister) Delete(provider *models.SamlIdentityProvider) error { + err := s.db.Destroy(provider) + + if err != nil { + return fmt.Errorf("failed to delete saml provider: %w", err) + } + + return nil +} diff --git a/backend/test/fixtures/saml/saml_attribute_maps.yaml b/backend/test/fixtures/saml/saml_attribute_maps.yaml new file mode 100644 index 000000000..7b45646f7 --- /dev/null +++ b/backend/test/fixtures/saml/saml_attribute_maps.yaml @@ -0,0 +1,22 @@ +- id: 734e1ae8-b039-48a8-9352-7b37626caa66 + name: "" + family_name: "" + given_name: "" + middle_name: "" + nickname: "" + preferred_username: "" + profile: "" + picture: "" + website: "" + gender: "" + birthdate: "" + zone_info: "" + locale: "" + saml_updated_at: "" + email: "" + email_verified: "" + phone: "" + phone_verified: "" + created_at: 2024-02-12 09:21:58.674033 + updated_at: 2024-02-12 09:21:58.674033 + saml_identity_provider_id: d531b0ae-4c33-48bb-ad31-e800a71a5056 diff --git a/backend/test/fixtures/saml/saml_certificates.yaml b/backend/test/fixtures/saml/saml_certificates.yaml new file mode 100644 index 000000000..929bed873 --- /dev/null +++ b/backend/test/fixtures/saml/saml_certificates.yaml @@ -0,0 +1,75 @@ +- id: f353553a-0bc7-413a-9442-cea60c32c99c + cert_data: |- + -----BEGIN CERTIFICATE----- + MIIDCDCCAfCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAnMSUwIwYDVQQDExxIYW5r + byBBdXRoZW50aWNhdGlvbiBTZXJ2aWNlMB4XDTI0MDIxMjA5MjE1OFoXDTI1MDIx + MTA5MjE1OFowJzElMCMGA1UEAxMcSGFua28gQXV0aGVudGljYXRpb24gU2Vydmlj + ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKFFKmsZSTgOvFiVySkk + kNYvuXb7cnv2d74uBPQzCJFU6RY7fRAFOPRmkZ+ICFIOW25/k8S6bn1igcPyAHhM + MAuVNO/S1uTAM+A+lkqkyKsjt1L5qrMYbqLXhBd1hMgsEi0jIzGzXFtX4h2B4dd5 + CtZd0oUHTxWC1Sv7bq0vt5CqcSRaGWN83HHkRySZ+tjtvfTNemzLqvmoQrDBWukL + 0XJnOs/sbw55sq2oNORQKpwinjGcNoJfrvEgDVXVDrSixHtx5RX03QRn3N7o+dhC + DIcp7yHx5n2GEcqLrCY9lniwLZZZ5IgWSOAwZK9N8WLlg6RJad1eIZ8ovdPYSuDM + Y70CAwEAAaM/MD0wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMB + BggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCFU4el + Octs4yngWHzolom3t2VC4IQjFwoh59qyXy0cYRgfLrclKdpxgPO556iG/G/UfTbH + 1sD9cZlmMAIMzFwfn63GaHuQ43QyvHwaq2xLxw1xPM5+kY8QlsourX5RByzJa00P + 6oLqpa4bHFSWKYoPr8UwHnSrDgC7PNFwxV9RKCmwRrjvEXoCDsRdEuZWHg2Vv4Zl + ehGR5+NbGlC9uARG2rWtq98YtJkV5z11NOvQYZF38M0IhY16vwQwsVXLeWYHMWDt + miCI2lCVoCPWwwUtu1kBSup/SdIhnhMXrvr5y+bQcMZ/T0zPFTWSTrTwLndIdTow + lPsIczvRBs2lFB1j + -----END CERTIFICATE----- + cert_key: 35Sldu4hpop-YJhOPwH95fRgNj1lNaJBBzhtVWPun5feWFG3ZqdzEFHiZzX8RHVkKGGNiTpd51F0LW0oNP8u6IqDDFQiLudrGEzT0R-NCK97b6gmzr-gleik1Bd1dM8CKBrytvynyXEGa8lieE39go3Z0rvh0BbpPOa7GETg-fKijrNkdNE-W813V8eJZ1fiyaQV3zTLk1vUv78Wd_yVxnGfeyVK_a0I76C9RhjkcAZmPf3J_PhF07cDlpOjdHZ84eRuiDaz3-8F2xjj3IuFVqpL1tRYAlp8EjbZznlKE9lGNHRzk1mfsFQfbwu1Lrpx_PIDWtke5KRp7p4toZsij2hPUhZra9pBkk0r4JxWxf7_yU-R4Joh7wQMCGErKu8UjdzOMbTEcf8b8wnXz05RM_HXTlG84X2sMnnJ7gNHU3xXu6b0y7xgiRTys718AOKaMymvDHvZh2BnjDMpOU8o3TuSKlhCGdcw6lHgBcxHn4dm-ivGMMiFYpaXVq7Wzt5kJjSEhlkOnU5kadgIs_VCqC-FbfTmr03FQ4LrVa_Xf5d9YTodpvdjpFi9KD3Gt-4lNwDFbGUTIURxdjymAe6UAhy6qLIaeAjbBOEGQvUN3D1Ob9bg3fO_0V7IHSsAUarHadf2vogn9c8sEvRHe8Huo7spjL2g15kEC0h2BFPjl673eWth3cykj5hcMaYGwZQoQaaqSpUudebRhrMYT9YO0xLJKdnyZYFARuNUUGesc6Fz7Xzr62oZADwNxrLdJndGWqaMfe470FbPwUgmUVcrDh7JYD-VvlSXrrVmxBgp1jhGawIeF5-c2iHERNgmsWYvdKYQEg4yYM8E_BE3FMFpvAmzRQ3YD19jwRvsv8V2gRqNfZQ1BA3lX4PiDVPLUvg-S_B3SRIj4owMrVcSBmqi4m60GQLgq_RWgVB0E0bwKqrq2JrU0Id0Ogbs1I1Ai2Uou_5Ap_luzLqZOdKu_df0BgZfc5Lg68qeY-AJipqBGxWvPnyMxW40doAqXAPA4r6_ua0Tz9yrRnkJkh9-mEGjgop0TG3px_Fa1Meh7mZPR2KVAYzW8Aa7tjNNowAGwjUyxw7qIjhJjGCHO-NiOrhazDJaPtBL0GLJ3-aTUslfAkqfHk-46ls_0jQ1QI_VU15Qi3MYPICKBn5EggQXcPSBpN9gEocwHiWsstV-fBa7WHtTKJ0hdruV0qfqHshDeJI_uukPZ2CZ47myuB4w-Iy3alNztN9V_LCqoPizyFO4okMNLsRWq7NYZSjrM6t2Ob_q-zToGE0_2upcXfeHW7lVIKgfUqObiYRTbGK54TknPQd9VrckVmsxf1oPwpUGlgpSrQEewSZ2WtcfDURtWPojCZL1GzoyZW3EbG03xqzNQHLzCTyNGncH69KKN3evgvB5_6uaWcy0JnwqUG_9I_uBUIZ6F2_Q8Qkc0-SWabewUYMDHIpRKI5zvK8Fe-4S3gDPJgq8Maec_kVaTWb99IwoVIPUd88KNbXCd4DhKUWJteDIT-dqJQxHvJktklQ5LwpVL87XtQCfvhwCWt3tXusPkEq9cFzp4A-dng8edF24hxrb0vuI2Juq3O9zrpTEsqLcFn1EJ5jmI-ojwP8R-M2evBas2PwHB1gYA2_HNJqcRR4FjBbpaQNFnS7B6l5yfz6ecJ3vn7rySB2PCHywJyfdUbwzx_aoqBNnYideZOzwxsihppI7ssQXQ2z46MBVo0Mt_lTq-DZ3HjeNWoUd1O6m27BNRn0VewrDnRSwx-HV_O1LQdwTk8IKiFFwyEy0ojr9iybBjFg7X9koa4JCFAsR1_kj50QqFwX0zKzXqFxMhJZXDEJ0jmVo7tlOq63E75M5ZZX_1DQhz88-n3YyYFrRPkZCkxWrMzLyYkV4WjSKEtljhIjRfaAiEBhLPKtP3Nqe4hiJEVC_T3MXVnd2HSpqn30Cuzdfylgj2szXnJdjQXAbqYmy5MccsX9bqjGIH468ZnUC0tTuSBSq1_KJsCfKmoW8atHAo4os4-PxtXC0PE--G6DKHRDpy18pAYzo9hyAt2IrM1hERyt6_Ba7SS4SsurnFn_yNosy-rafwcfN2r3LSBUDO5wcZJVfsMTy-v2XT0S5AxgUGT1BiAGaDpWauAT5ZJHVsysUQVRWAl4wLcU9W9nA12xwFRDYRX90V4YeJjHEej_Ea0hSSCCkO8gM_KPWhQ87mso5lBm1zjdN5V6ZtObsoPZUfLyr_kg823dJgjTztBdN1XT35TrYYUYa7QkBk8y3EwM= + encryption_key: uVk8akK_79it19iQs30SD_Is1ew66uMGrpWLF7isvdE= + created_at: 2024-02-12 09:21:58.674033 + updated_at: 2024-02-12 09:21:58.741405 +- id: f353553a-0bc7-413a-9442-cea60c32c99d + cert_data: |- + -----BEGIN CERTIFICATE----- + MIIDCDCCAfCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAnMSUwIwYDVQQDExxIYW5r + byBBdXRoZW50aWNhdGlvbiBTZXJ2aWNlMB4XDTI0MDIxMjA5MjE1OFoXDTI1MDIx + MTA5MjE1OFowJzElMCMGA1UEAxMcSGFua28gQXV0aGVudGljYXRpb24gU2Vydmlj + ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKFFKmsZSTgOvFiVySkk + kNYvuXb7cnv2d74uBPQzCJFU6RY7fRAFOPRmkZ+ICFIOW25/k8S6bn1igcPyAHhM + MAuVNO/S1uTAM+A+lkqkyKsjt1L5qrMYbqLXhBd1hMgsEi0jIzGzXFtX4h2B4dd5 + CtZd0oUHTxWC1Sv7bq0vt5CqcSRaGWN83HHkRySZ+tjtvfTNemzLqvmoQrDBWukL + 0XJnOs/sbw55sq2oNORQKpwinjGcNoJfrvEgDVXVDrSixHtx5RX03QRn3N7o+dhC + DIcp7yHx5n2GEcqLrCY9lniwLZZZ5IgWSOAwZK9N8WLlg6RJad1eIZ8ovdPYSuDM + Y70CAwEAAaM/MD0wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMB + BggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCFU4el + Octs4yngWHzolom3t2VC4IQjFwoh59qyXy0cYRgfLrclKdpxgPO556iG/G/UfTbH + 1sD9cZlmMAIMzFwfn63GaHuQ43QyvHwaq2xLxw1xPM5+kY8QlsourX5RByzJa00P + 6oLqpa4bHFSWKYoPr8UwHnSrDgC7PNFwxV9RKCmwRrjvEXoCDsRdEuZWHg2Vv4Zl + ehGR5+NbGlC9uARG2rWtq98YtJkV5z11NOvQYZF38M0IhY16vwQwsVXLeWYHMWDt + miCI2lCVoCPWwwUtu1kBSup/SdIhnhMXrvr5y+bQcMZ/T0zPFTWSTrTwLndIdTow + lPsIczvRBs2lFB1j + -----END CERTIFICATE----- + cert_key: wrong-crypt-key + encryption_key: uVk8akK_79it19iQs30SD_Is1ew66uMGrpWLF7isvdE= + created_at: 2024-02-12 09:21:58.674033 + updated_at: 2024-02-12 09:21:58.741405 +- id: f353553a-0bc7-413a-9442-cea60c32c99e + cert_data: |- + -----BEGIN CERTIFICATE----- + MIIDCDCCAfCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAnMSUwIwYDVQQDExxIYW5r + byBBdXRoZW50aWNhdGlvbiBTZXJ2aWNlMB4XDTI0MDIxMjA5MjE1OFoXDTI1MDIx + MTA5MjE1OFowJzElMCMGA1UEAxMcSGFua28gQXV0aGVudGljYXRpb24gU2Vydmlj + ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKFFKmsZSTgOvFiVySkk + kNYvuXb7cnv2d74uBPQzCJFU6RY7fRAFOPRmkZ+ICFIOW25/k8S6bn1igcPyAHhM + MAuVNO/S1uTAM+A+lkqkyKsjt1L5qrMYbqLXhBd1hMgsEi0jIzGzXFtX4h2B4dd5 + CtZd0oUHTxWC1Sv7bq0vt5CqcSRaGWN83HHkRySZ+tjtvfTNemzLqvmoQrDBWukL + 0XJnOs/sbw55sq2oNORQKpwinjGcNoJfrvEgDVXVDrSixHtx5RX03QRn3N7o+dhC + DIcp7yHx5n2GEcqLrCY9lniwLZZZ5IgWSOAwZK9N8WLlg6RJad1eIZ8ovdPYSuDM + Y70CAwEAAaM/MD0wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMB + BggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCFU4el + Octs4yngWHzolom3t2VC4IQjFwoh59qyXy0cYRgfLrclKdpxgPO556iG/G/UfTbH + 1sD9cZlmMAIMzFwfn63GaHuQ43QyvHwaq2xLxw1xPM5+kY8QlsourX5RByzJa00P + 6oLqpa4bHFSWKYoPr8UwHnSrDgC7PNFwxV9RKCmwRrjvEXoCDsRdEuZWHg2Vv4Zl + ehGR5+NbGlC9uARG2rWtq98YtJkV5z11NOvQYZF38M0IhY16vwQwsVXLeWYHMWDt + miCI2lCVoCPWwwUtu1kBSup/SdIhnhMXrvr5y+bQcMZ/T0zPFTWSTrTwLndIdTow + lPsIczvRBs2lFB1j + -----END CERTIFICATE----- + cert_key: 35Sldu4hpop-YJhOPwH95fRgNj1lNaJBBzhtVWPun5feWFG3ZqdzEFHiZzX8RHVkKGGNiTpd51F0LW0oNP8u6IqDDFQiLudrGEzT0R-NCK97b6gmzr-gleik1Bd1dM8CKBrytvynyXEGa8lieE39go3Z0rvh0BbpPOa7GETg-fKijrNkdNE-W813V8eJZ1fiyaQV3zTLk1vUv78Wd_yVxnGfeyVK_a0I76C9RhjkcAZmPf3J_PhF07cDlpOjdHZ84eRuiDaz3-8F2xjj3IuFVqpL1tRYAlp8EjbZznlKE9lGNHRzk1mfsFQfbwu1Lrpx_PIDWtke5KRp7p4toZsij2hPUhZra9pBkk0r4JxWxf7_yU-R4Joh7wQMCGErKu8UjdzOMbTEcf8b8wnXz05RM_HXTlG84X2sMnnJ7gNHU3xXu6b0y7xgiRTys718AOKaMymvDHvZh2BnjDMpOU8o3TuSKlhCGdcw6lHgBcxHn4dm-ivGMMiFYpaXVq7Wzt5kJjSEhlkOnU5kadgIs_VCqC-FbfTmr03FQ4LrVa_Xf5d9YTodpvdjpFi9KD3Gt-4lNwDFbGUTIURxdjymAe6UAhy6qLIaeAjbBOEGQvUN3D1Ob9bg3fO_0V7IHSsAUarHadf2vogn9c8sEvRHe8Huo7spjL2g15kEC0h2BFPjl673eWth3cykj5hcMaYGwZQoQaaqSpUudebRhrMYT9YO0xLJKdnyZYFARuNUUGesc6Fz7Xzr62oZADwNxrLdJndGWqaMfe470FbPwUgmUVcrDh7JYD-VvlSXrrVmxBgp1jhGawIeF5-c2iHERNgmsWYvdKYQEg4yYM8E_BE3FMFpvAmzRQ3YD19jwRvsv8V2gRqNfZQ1BA3lX4PiDVPLUvg-S_B3SRIj4owMrVcSBmqi4m60GQLgq_RWgVB0E0bwKqrq2JrU0Id0Ogbs1I1Ai2Uou_5Ap_luzLqZOdKu_df0BgZfc5Lg68qeY-AJipqBGxWvPnyMxW40doAqXAPA4r6_ua0Tz9yrRnkJkh9-mEGjgop0TG3px_Fa1Meh7mZPR2KVAYzW8Aa7tjNNowAGwjUyxw7qIjhJjGCHO-NiOrhazDJaPtBL0GLJ3-aTUslfAkqfHk-46ls_0jQ1QI_VU15Qi3MYPICKBn5EggQXcPSBpN9gEocwHiWsstV-fBa7WHtTKJ0hdruV0qfqHshDeJI_uukPZ2CZ47myuB4w-Iy3alNztN9V_LCqoPizyFO4okMNLsRWq7NYZSjrM6t2Ob_q-zToGE0_2upcXfeHW7lVIKgfUqObiYRTbGK54TknPQd9VrckVmsxf1oPwpUGlgpSrQEewSZ2WtcfDURtWPojCZL1GzoyZW3EbG03xqzNQHLzCTyNGncH69KKN3evgvB5_6uaWcy0JnwqUG_9I_uBUIZ6F2_Q8Qkc0-SWabewUYMDHIpRKI5zvK8Fe-4S3gDPJgq8Maec_kVaTWb99IwoVIPUd88KNbXCd4DhKUWJteDIT-dqJQxHvJktklQ5LwpVL87XtQCfvhwCWt3tXusPkEq9cFzp4A-dng8edF24hxrb0vuI2Juq3O9zrpTEsqLcFn1EJ5jmI-ojwP8R-M2evBas2PwHB1gYA2_HNJqcRR4FjBbpaQNFnS7B6l5yfz6ecJ3vn7rySB2PCHywJyfdUbwzx_aoqBNnYideZOzwxsihppI7ssQXQ2z46MBVo0Mt_lTq-DZ3HjeNWoUd1O6m27BNRn0VewrDnRSwx-HV_O1LQdwTk8IKiFFwyEy0ojr9iybBjFg7X9koa4JCFAsR1_kj50QqFwX0zKzXqFxMhJZXDEJ0jmVo7tlOq63E75M5ZZX_1DQhz88-n3YyYFrRPkZCkxWrMzLyYkV4WjSKEtljhIjRfaAiEBhLPKtP3Nqe4hiJEVC_T3MXVnd2HSpqn30Cuzdfylgj2szXnJdjQXAbqYmy5MccsX9bqjGIH468ZnUC0tTuSBSq1_KJsCfKmoW8atHAo4os4-PxtXC0PE--G6DKHRDpy18pAYzo9hyAt2IrM1hERyt6_Ba7SS4SsurnFn_yNosy-rafwcfN2r3LSBUDO5wcZJVfsMTy-v2XT0S5AxgUGT1BiAGaDpWauAT5ZJHVsysUQVRWAl4wLcU9W9nA12xwFRDYRX90V4YeJjHEej_Ea0hSSCCkO8gM_KPWhQ87mso5lBm1zjdN5V6ZtObsoPZUfLyr_kg823dJgjTztBdN1XT35TrYYUYa7QkBk8y3EwM= + encryption_key: wrong-encryption-key + created_at: 2024-02-12 09:21:58.674033 + updated_at: 2024-02-12 09:21:58.741405 diff --git a/backend/test/fixtures/saml/saml_identity_providers.yaml b/backend/test/fixtures/saml/saml_identity_providers.yaml new file mode 100644 index 000000000..163c32f23 --- /dev/null +++ b/backend/test/fixtures/saml/saml_identity_providers.yaml @@ -0,0 +1,16 @@ +- id: d531b0ae-4c33-48bb-ad31-e800a71a5056 + enabled: true + name: "hanko" + domain: "hanko.io" + metadata_url: "https://localhost/metadata" + skip_email_verification: false + created_at: 2024-02-12 09:21:58.674033 + updated_at: 2024-02-12 09:21:58.674033 +- id: d531b0ae-4c33-48bb-ad31-e800a71a5057 + enabled: true + name: "local" + domain: "localhost" + metadata_url: "https://localhost/metadata" + skip_email_verification: false + created_at: 2024-02-12 09:21:58.674033 + updated_at: 2024-02-12 09:21:58.674033 diff --git a/backend/test/persister.go b/backend/test/persister.go index 603c59a01..b9ed470b0 100644 --- a/backend/test/persister.go +++ b/backend/test/persister.go @@ -175,3 +175,7 @@ func (p *persister) GetSamlCertificatePersisterWithConnection(tx *pop.Connection func (p *persister) GetWebhookPersister(_ *pop.Connection) persistence.WebhookPersister { return p.webhookPersister } + +func (p *persister) GetSamlIdentityProviderPersister(tx *pop.Connection) persistence.SamlIdentityProviderPersister { + return nil // not used anymore but breaks tests... +} From 3fd93f0257324b73a5320308a21b9aac65a97f65 Mon Sep 17 00:00:00 2001 From: Stefan Jacobi Date: Tue, 20 Feb 2024 10:46:13 +0100 Subject: [PATCH 3/3] chore(saml): improve docs * add note for checking and debugging attribute fields * add chapter to inform about the possibility to persist SAML providers to database Related to: #1295 --- docs/docs/guides/ee/saml.mdx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/docs/guides/ee/saml.mdx b/docs/docs/guides/ee/saml.mdx index 1f1c8678d..02a12a635 100644 --- a/docs/docs/guides/ee/saml.mdx +++ b/docs/docs/guides/ee/saml.mdx @@ -108,6 +108,14 @@ Explanation of all tags: Every IDP-Attribute which is not a hanko field will be mapped into a custom claim map of type `map[string]string` where the key of an entry is the attribute name and the value of an entry is the attribute value. +*Please check if all your attributes are mapped correctly. E.g. in Microsoft AD your email address to log in is not necessarily represented in the `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` field.* +If you feel that you are missing fields, you can use extensions like `SAML-tracer` (Google Chrome/Chromium) to check the assertions exchanged and all fields in them. + +### Configure your Identity Provider in database + +If you have a great number of identity providers to support you can also persist them to the database by using the admin api endpoints for SAML identity providers. +The endpoint takes the same parameters as the config file one. + ### Additional Attributes For some providers we also provide some additional attributes. The provider will be extracted from the metadata url (e.g. `https://.eu.auth0.com/samlp/metadata/` will load defaults for auth0). Currently, there the following extra defaults are provided for the following providers: @@ -125,7 +133,7 @@ will scratch the `http://schemas.auth0.com/auth0/` part, and you have to provide ## Configure Identity Provider -To configure your entity provider you will mabye need the following parameters: +To configure your entity provider you will need the following parameters: * Callback-URL: This will be `/callback` (e.g.: ENDPOINT_URL: http://localhost:8000 -> http://localhost:8000/saml/callback) * Service Provider Metadata URL: This will be `/metadata?domain=` (e.g.: ENDPOINT_URL: http://localhost:8000 , DOMAIN: test.example -> http://localhost:8000/saml/metadata?domain=test.example)