From 579cfdc678df6549f44bb2a6ec0d2a6f6df6ac1f Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Fri, 2 Jan 2026 17:54:20 +0100 Subject: [PATCH] feat: add support for SCIM provisioning (#1182) --- .github/workflows/e2e-tests.yml | 22 +- .../internal/bootstrap/router_bootstrap.go | 1 + .../internal/bootstrap/services_bootstrap.go | 39 +- .../internal/controller/oidc_controller.go | 28 + .../internal/controller/scim_controller.go | 122 +++ backend/internal/dto/scim_dto.go | 96 +++ backend/internal/model/scim.go | 14 + .../internal/model/types/encrypted_string.go | 91 ++ backend/internal/model/user.go | 9 + backend/internal/model/user_group.go | 14 + backend/internal/service/e2etest_service.go | 25 + backend/internal/service/oidc_service.go | 23 +- .../service/scim_scheduler_service.go | 136 +++ backend/internal/service/scim_service.go | 774 ++++++++++++++++++ .../internal/service/user_group_service.go | 5 + backend/internal/service/user_service.go | 12 + backend/internal/utils/sleep_util.go | 21 + ...1229173100_scim_service_providers.down.sql | 3 + ...251229173100_scim_service_providers.up.sql | 15 + ...1229173100_scim_service_providers.down.sql | 9 + ...251229173100_scim_service_providers.up.sql | 22 + frontend/messages/en.json | 14 + .../lib/components/collapsible-card.svelte | 3 +- frontend/src/lib/services/oidc-service.ts | 6 + frontend/src/lib/services/scim-service.ts | 27 + frontend/src/lib/types/scim.type.ts | 14 + .../admin/oidc-clients/[id]/+page.svelte | 49 +- .../settings/admin/oidc-clients/[id]/+page.ts | 11 +- .../[id]/scim-resource-provider-form.svelte | 144 ++++ tests/data.ts | 6 + tests/resources/export/database.json | 44 +- tests/setup/docker-compose-postgres.yml | 4 + tests/setup/docker-compose-s3.yml | 7 +- tests/setup/docker-compose.yml | 4 + tests/specs/apps-dashboard.spec.ts | 6 +- tests/specs/scim.spec.ts | 172 ++++ tests/utils/cleanup.util.ts | 5 + 37 files changed, 1963 insertions(+), 34 deletions(-) create mode 100644 backend/internal/controller/scim_controller.go create mode 100644 backend/internal/dto/scim_dto.go create mode 100644 backend/internal/model/scim.go create mode 100644 backend/internal/model/types/encrypted_string.go create mode 100644 backend/internal/service/scim_scheduler_service.go create mode 100644 backend/internal/service/scim_service.go create mode 100644 backend/internal/utils/sleep_util.go create mode 100644 backend/resources/migrations/postgres/20251229173100_scim_service_providers.down.sql create mode 100644 backend/resources/migrations/postgres/20251229173100_scim_service_providers.up.sql create mode 100644 backend/resources/migrations/sqlite/20251229173100_scim_service_providers.down.sql create mode 100644 backend/resources/migrations/sqlite/20251229173100_scim_service_providers.up.sql create mode 100644 frontend/src/lib/services/scim-service.ts create mode 100644 frontend/src/lib/types/scim.type.ts create mode 100644 frontend/src/routes/settings/admin/oidc-clients/[id]/scim-resource-provider-form.svelte create mode 100644 tests/specs/scim.spec.ts diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b87b0d32..e8ae44a6 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -117,6 +117,21 @@ jobs: if: steps.lldap-cache.outputs.cache-hit == 'true' run: docker load < /tmp/lldap-image.tar + - name: Cache SCIM Test Server Docker image + uses: actions/cache@v4 + id: scim-cache + with: + path: /tmp/scim-test-server-image.tar + key: scim-test-server-${{ runner.os }} + - name: Pull and save SCIM Test Server image + if: steps.scim-cache.outputs.cache-hit != 'true' + run: | + docker pull ghcr.io/pocket-id/scim-test-server + docker save ghcr.io/pocket-id/scim-test-server > /tmp/scim-test-server-image.tar + - name: Load SCIM Test Server image + if: steps.scim-cache.outputs.cache-hit == 'true' + run: docker load < /tmp/scim-test-server-image.tar + - name: Cache Localstack S3 Docker image if: matrix.storage == 's3' uses: actions/cache@v4 @@ -171,7 +186,12 @@ jobs: run: | DOCKER_COMPOSE_FILE=docker-compose.yml - echo "FILE_BACKEND=${{ matrix.storage }}" > .env + cat > .env <= providerRefreshDelay { + err := s.refreshProviders(ctx) + if err != nil { + slog.Error("Error refreshing SCIM service providers", + slog.Any("error", err), + ) + } else { + lastProviderRefresh = now + } + } + + var due []string + s.mu.RLock() + for providerID, syncTime := range s.providerSyncTime { + if !syncTime.After(now) { + due = append(due, providerID) + } + } + s.mu.RUnlock() + + s.syncProviders(ctx, due) + + } + } + }() + + return nil +} + +func (s *ScimSchedulerService) refreshProviders(ctx context.Context) error { + providers, err := s.scimService.ListServiceProviders(ctx) + if err != nil { + return err + } + + inAHour := time.Now().Add(time.Hour) + + s.mu.Lock() + for _, provider := range providers { + if _, exists := s.providerSyncTime[provider.ID]; !exists { + s.providerSyncTime[provider.ID] = inAHour + } + } + s.mu.Unlock() + + return nil +} + +func (s *ScimSchedulerService) syncProviders(ctx context.Context, providerIDs []string) { + for _, providerID := range providerIDs { + err := s.scimService.SyncServiceProvider(ctx, providerID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // Remove the provider from the schedule if it no longer exists + s.mu.Lock() + delete(s.providerSyncTime, providerID) + s.mu.Unlock() + } else { + slog.Error("Error syncing SCIM client", + slog.String("provider_id", providerID), + slog.Any("error", err), + ) + } + continue + } + // A successful sync schedules the next sync in an hour + s.setSyncTime(providerID, time.Hour) + } +} + +func (s *ScimSchedulerService) setSyncTime(providerID string, t time.Duration) { + s.mu.Lock() + s.providerSyncTime[providerID] = time.Now().Add(t) + s.mu.Unlock() +} diff --git a/backend/internal/service/scim_service.go b/backend/internal/service/scim_service.go new file mode 100644 index 00000000..938174aa --- /dev/null +++ b/backend/internal/service/scim_service.go @@ -0,0 +1,774 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "time" + + "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/model" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/utils" + "gorm.io/gorm" +) + +const ( + scimUserSchema = "urn:ietf:params:scim:schemas:core:2.0:User" + scimGroupSchema = "urn:ietf:params:scim:schemas:core:2.0:Group" + scimContentType = "application/scim+json" +) + +const scimErrorBodyLimit = 4096 + +type scimSyncAction int + +const ( + scimActionNone scimSyncAction = iota + scimActionCreated + scimActionUpdated + scimActionDeleted +) + +type scimSyncStats struct { + Created int + Updated int + Deleted int +} + +// ScimService handles SCIM provisioning to external service providers. +type ScimService struct { + db *gorm.DB + httpClient *http.Client +} + +func NewScimService(db *gorm.DB, httpClient *http.Client) *ScimService { + if httpClient == nil { + httpClient = &http.Client{Timeout: 20 * time.Second} + } + + return &ScimService{db: db, httpClient: httpClient} +} + +func (s *ScimService) GetServiceProvider( + ctx context.Context, + serviceProviderID string, +) (model.ScimServiceProvider, error) { + var provider model.ScimServiceProvider + err := s.db.WithContext(ctx). + Preload("OidcClient"). + Preload("OidcClient.AllowedUserGroups"). + First(&provider, "id = ?", serviceProviderID). + Error + if err != nil { + return model.ScimServiceProvider{}, err + } + return provider, nil +} + +func (s *ScimService) ListServiceProviders(ctx context.Context) ([]model.ScimServiceProvider, error) { + var providers []model.ScimServiceProvider + err := s.db.WithContext(ctx). + Preload("OidcClient"). + Find(&providers). + Error + if err != nil { + return nil, err + } + return providers, nil +} + +func (s *ScimService) CreateServiceProvider( + ctx context.Context, + input *dto.ScimServiceProviderCreateDTO) (model.ScimServiceProvider, error) { + provider := model.ScimServiceProvider{ + Endpoint: input.Endpoint, + Token: datatype.EncryptedString(input.Token), + OidcClientID: input.OidcClientID, + } + + if err := s.db.WithContext(ctx).Create(&provider).Error; err != nil { + return model.ScimServiceProvider{}, err + } + + return provider, nil +} + +func (s *ScimService) UpdateServiceProvider(ctx context.Context, + serviceProviderID string, + input *dto.ScimServiceProviderCreateDTO, +) (model.ScimServiceProvider, error) { + var provider model.ScimServiceProvider + err := s.db.WithContext(ctx). + First(&provider, "id = ?", serviceProviderID). + Error + if err != nil { + return model.ScimServiceProvider{}, err + } + + provider.Endpoint = input.Endpoint + provider.Token = datatype.EncryptedString(input.Token) + provider.OidcClientID = input.OidcClientID + + if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil { + return model.ScimServiceProvider{}, err + } + + return provider, nil +} + +func (s *ScimService) DeleteServiceProvider(ctx context.Context, serviceProviderID string) error { + return s.db.WithContext(ctx). + Delete(&model.ScimServiceProvider{}, "id = ?", serviceProviderID). + Error +} + +func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID string) error { + start := time.Now() + provider, err := s.GetServiceProvider(ctx, serviceProviderID) + if err != nil { + return err + } + + slog.InfoContext(ctx, "Syncing SCIM service provider", + slog.String("provider_id", provider.ID), + slog.String("oidc_client_id", provider.OidcClientID), + ) + + allowedGroupIDs := groupIDs(provider.OidcClient.AllowedUserGroups) + + // Load users and groups that should be synced to the SCIM provider + groups, err := s.groupsForClient(ctx, provider.OidcClient, allowedGroupIDs) + if err != nil { + return err + } + users, err := s.usersForClient(ctx, provider.OidcClient, allowedGroupIDs) + if err != nil { + return err + } + + // Load users and groups that already exist in the SCIM provider + userResources, err := listScimResources[dto.ScimUser](s, ctx, provider, "/Users") + if err != nil { + return err + } + groupResources, err := listScimResources[dto.ScimGroup](s, ctx, provider, "/Groups") + if err != nil { + return err + } + + var errs []error + var userStats scimSyncStats + var groupStats scimSyncStats + + // Sync users first, so that groups can reference them + if stats, err := s.syncUsers(ctx, provider, users, &userResources); err != nil { + errs = append(errs, err) + userStats = stats + } else { + userStats = stats + } + + stats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources) + if err != nil { + errs = append(errs, err) + groupStats = stats + } else { + groupStats = stats + } + + if len(errs) > 0 { + slog.WarnContext(ctx, "SCIM sync completed with errors", + slog.String("provider_id", provider.ID), + slog.Int("error_count", len(errs)), + slog.Int("users_created", userStats.Created), + slog.Int("users_updated", userStats.Updated), + slog.Int("users_deleted", userStats.Deleted), + slog.Int("groups_created", groupStats.Created), + slog.Int("groups_updated", groupStats.Updated), + slog.Int("groups_deleted", groupStats.Deleted), + slog.Duration("duration", time.Since(start)), + ) + return errors.Join(errs...) + } + + provider.LastSyncedAt = utils.Ptr(datatype.DateTime(time.Now())) + if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil { + return err + } + + slog.InfoContext(ctx, "SCIM sync completed", + slog.String("provider_id", provider.ID), + slog.Int("users_created", userStats.Created), + slog.Int("users_updated", userStats.Updated), + slog.Int("users_deleted", userStats.Deleted), + slog.Int("groups_created", groupStats.Created), + slog.Int("groups_updated", groupStats.Updated), + slog.Int("groups_deleted", groupStats.Deleted), + slog.Duration("duration", time.Since(start)), + ) + + return nil +} + +func (s *ScimService) syncUsers( + ctx context.Context, + provider model.ScimServiceProvider, + users []model.User, + resourceList *dto.ScimListResponse[dto.ScimUser], +) (stats scimSyncStats, err error) { + var errs []error + + // Update or create users + for _, u := range users { + existing := getResourceByExternalID[dto.ScimUser](u.ID, resourceList.Resources) + + action, created, err := s.syncUser(ctx, provider, u, existing) + if created != nil && existing == nil { + resourceList.Resources = append(resourceList.Resources, *created) + } + if err != nil { + errs = append(errs, err) + continue + } + + // Update stats based on action taken by syncUser + switch action { + case scimActionCreated: + stats.Created++ + case scimActionUpdated: + stats.Updated++ + case scimActionDeleted: + stats.Deleted++ + case scimActionNone: + } + } + + // Delete users that are present in SCIM provider but not locally. + userSet := make(map[string]struct{}) + for _, u := range users { + userSet[u.ID] = struct{}{} + } + + for _, r := range resourceList.Resources { + if _, ok := userSet[r.ExternalID]; !ok { + if err := s.deleteScimResource(ctx, provider, "/Users/"+url.PathEscape(r.ID)); err != nil { + errs = append(errs, err) + } else { + stats.Deleted++ + } + } + } + + return stats, errors.Join(errs...) +} + +func (s *ScimService) syncGroups( + ctx context.Context, + provider model.ScimServiceProvider, + groups []model.UserGroup, + remoteGroups []dto.ScimGroup, + userResources []dto.ScimUser, +) (stats scimSyncStats, err error) { + var errs []error + + // Update or create groups + for _, g := range groups { + existing := getResourceByExternalID[dto.ScimGroup](g.ID, remoteGroups) + + action, err := s.syncGroup(ctx, provider, g, existing, userResources) + if err != nil { + errs = append(errs, err) + continue + } + + // Update stats based on action taken by syncGroup + switch action { + case scimActionCreated: + stats.Created++ + case scimActionUpdated: + stats.Updated++ + case scimActionDeleted: + stats.Deleted++ + case scimActionNone: + } + + } + + // Delete groups that are present in SCIM provider but not locally + groupSet := make(map[string]struct{}) + for _, g := range groups { + groupSet[g.ID] = struct{}{} + } + + for _, r := range remoteGroups { + if _, ok := groupSet[r.ExternalID]; !ok { + if err := s.deleteScimResource(ctx, provider, "/Groups/"+url.PathEscape(r.GetID())); err != nil { + errs = append(errs, err) + } else { + stats.Deleted++ + } + } + } + + return stats, errors.Join(errs...) +} + +func (s *ScimService) syncUser(ctx context.Context, + provider model.ScimServiceProvider, + user model.User, + userResource *dto.ScimUser, +) (scimSyncAction, *dto.ScimUser, error) { + // If user is not allowed for the client, delete it from SCIM provider + if userResource != nil && !IsUserGroupAllowedToAuthorize(user, provider.OidcClient) { + return scimActionDeleted, nil, s.deleteScimResource(ctx, provider, fmt.Sprintf("/Users/%s", url.PathEscape(userResource.ID))) + } + + payload := dto.ScimUser{ + ScimResourceData: dto.ScimResourceData{ + Schemas: []string{scimUserSchema}, + ExternalID: user.ID, + }, + UserName: user.Username, + Name: &dto.ScimName{ + GivenName: user.FirstName, + FamilyName: user.LastName, + }, + Display: user.DisplayName, + Active: !user.Disabled, + } + + if user.Email != nil { + payload.Emails = []dto.ScimEmail{{ + Value: *user.Email, + Primary: true, + }} + } + + // If the user exists on the SCIM provider, and it has been modified, update it + if userResource != nil { + if user.LastModified().Before(userResource.GetMeta().LastModified) { + return scimActionNone, nil, nil + } + path := fmt.Sprintf("/Users/%s", url.PathEscape(userResource.GetID())) + userResource, err := updateScimResource(s, ctx, provider, path, payload) + if err != nil { + return scimActionNone, nil, err + } + return scimActionUpdated, userResource, nil + } + + // Otherwise, create a new SCIM user + userResource, err := createScimResource(s, ctx, provider, "/Users", payload) + if err != nil { + return scimActionNone, nil, err + } + + return scimActionCreated, userResource, nil +} + +func (s *ScimService) syncGroup( + ctx context.Context, + provider model.ScimServiceProvider, + group model.UserGroup, + groupResource *dto.ScimGroup, + userResources []dto.ScimUser, +) (scimSyncAction, error) { + // If group is not allowed for the client, delete it from SCIM provider + if groupResource != nil && !groupAllowedForClient(group.ID, provider.OidcClient) { + return scimActionDeleted, s.deleteScimResource(ctx, provider, fmt.Sprintf("/Groups/%s", url.PathEscape(groupResource.GetID()))) + } + + // Prepare group members + members := make([]dto.ScimGroupMember, len(group.Users)) + for i, user := range group.Users { + userResource := getResourceByExternalID[dto.ScimUser](user.ID, userResources) + if userResource == nil { + // Groups depend on user IDs already being provisioned + return scimActionNone, fmt.Errorf("cannot sync group %s: user %s is not provisioned in SCIM provider", group.ID, user.ID) + } + + members[i] = dto.ScimGroupMember{ + Value: userResource.GetID(), + } + } + + groupPayload := dto.ScimGroup{ + ScimResourceData: dto.ScimResourceData{ + Schemas: []string{scimGroupSchema}, + ExternalID: group.ID, + }, + Display: group.FriendlyName, + Members: members, + } + + // If the group exists on the SCIM provider, and it has been modified, update it + if groupResource != nil { + if group.LastModified().Before(groupResource.GetMeta().LastModified) { + return scimActionNone, nil + } + path := fmt.Sprintf("/Groups/%s", url.PathEscape(groupResource.GetID())) + _, err := updateScimResource(s, ctx, provider, path, groupPayload) + if err != nil { + return scimActionNone, err + } + return scimActionUpdated, nil + } + + // Otherwise, create a new SCIM group + _, err := createScimResource(s, ctx, provider, "/Groups", groupPayload) + if err != nil { + return scimActionNone, err + } + + return scimActionCreated, nil +} + +func groupAllowedForClient(groupID string, client model.OidcClient) bool { + if !client.IsGroupRestricted { + return true + } + + for _, allowedGroup := range client.AllowedUserGroups { + if allowedGroup.ID == groupID { + return true + } + } + + return false +} + +func groupIDs(groups []model.UserGroup) []string { + ids := make([]string, len(groups)) + for i, g := range groups { + ids[i] = g.ID + } + return ids +} + +func (s *ScimService) groupsForClient( + ctx context.Context, + client model.OidcClient, + allowedGroupIDs []string, +) ([]model.UserGroup, error) { + var groups []model.UserGroup + + query := s.db.WithContext(ctx).Preload("Users").Model(&model.UserGroup{}) + if client.IsGroupRestricted { + if len(allowedGroupIDs) == 0 { + return groups, nil + } + query = query.Where("id IN ?", allowedGroupIDs) + } + + if err := query.Find(&groups).Error; err != nil { + return nil, err + } + return groups, nil +} + +func (s *ScimService) usersForClient( + ctx context.Context, + client model.OidcClient, + allowedGroupIDs []string, +) ([]model.User, error) { + var users []model.User + + query := s.db.WithContext(ctx).Model(&model.User{}) + if client.IsGroupRestricted { + if len(allowedGroupIDs) == 0 { + return users, nil + } + query = query. + Joins("JOIN user_groups_users ON users.id = user_groups_users.user_id"). + Where("user_groups_users.user_group_id IN ?", allowedGroupIDs). + Select("users.*"). + Distinct() + } + + query = query.Preload("UserGroups") + + if err := query.Find(&users).Error; err != nil { + return nil, err + } + return users, nil +} + +func getResourceByExternalID[T dto.ScimResource](externalID string, resource []T) *T { + for i := range resource { + if resource[i].GetExternalID() == externalID { + return &resource[i] + } + } + return nil +} + +func listScimResources[T any]( + s *ScimService, + ctx context.Context, + provider model.ScimServiceProvider, + path string, +) (result dto.ScimListResponse[T], err error) { + startIndex := 1 + count := 1000 + + for { + // Use SCIM pagination to avoid missing resources on large providers + queryParams := map[string]string{ + "startIndex": strconv.Itoa(startIndex), + "count": strconv.Itoa(count), + } + + resp, err := s.scimRequest(ctx, provider, http.MethodGet, path, nil, queryParams) + if err != nil { + return dto.ScimListResponse[T]{}, err + } + + if err := ensureScimStatus(ctx, resp, provider, http.StatusOK); err != nil { + return dto.ScimListResponse[T]{}, err + } + + var page dto.ScimListResponse[T] + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + return dto.ScimListResponse[T]{}, fmt.Errorf("failed to decode SCIM list response: %w", err) + } + + resp.Body.Close() + + // Initialize metadata only once + if result.TotalResults == 0 { + result.TotalResults = page.TotalResults + } + + result.Resources = append(result.Resources, page.Resources...) + + // If we've fetched everything, stop + if len(result.Resources) >= page.TotalResults || len(page.Resources) == 0 { + break + } + + startIndex += page.ItemsPerPage + } + + result.ItemsPerPage = len(result.Resources) + return result, nil +} + +func createScimResource[T dto.ScimResource]( + s *ScimService, + ctx context.Context, + provider model.ScimServiceProvider, + path string, payload T) (*T, error) { + resp, err := s.scimRequest(ctx, provider, http.MethodPost, path, payload, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if err := ensureScimStatus(ctx, resp, provider, http.StatusOK, http.StatusCreated); err != nil { + return nil, err + } + + var resource T + if err := json.NewDecoder(resp.Body).Decode(&resource); err != nil { + return nil, fmt.Errorf("failed to decode SCIM create response: %w", err) + } + + return &resource, nil +} + +func updateScimResource[T dto.ScimResource]( + s *ScimService, + ctx context.Context, + provider model.ScimServiceProvider, + path string, + payload T, +) (*T, error) { + resp, err := s.scimRequest(ctx, provider, http.MethodPut, path, payload, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if err := ensureScimStatus(ctx, resp, provider, http.StatusOK, http.StatusCreated); err != nil { + return nil, err + } + + var resource T + if err := json.NewDecoder(resp.Body).Decode(&resource); err != nil { + return nil, fmt.Errorf("failed to decode SCIM update response: %w", err) + } + + return &resource, nil +} + +func (s *ScimService) deleteScimResource(ctx context.Context, provider model.ScimServiceProvider, path string) error { + resp, err := s.scimRequest(ctx, provider, http.MethodDelete, path, nil, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil + } + + return ensureScimStatus(ctx, resp, provider, http.StatusOK, http.StatusNoContent) +} + +func (s *ScimService) scimRequest( + ctx context.Context, + provider model.ScimServiceProvider, + method, + path string, + payload any, + queryParams map[string]string, +) (*http.Response, error) { + urlString, err := scimURL(provider.Endpoint, path, queryParams) + if err != nil { + return nil, err + } + + var bodyBytes []byte + if payload != nil { + encoded, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to encode SCIM payload: %w", err) + } + bodyBytes = encoded + } + + retryAttempts := 3 + for attempt := 1; attempt <= retryAttempts; attempt++ { + var body io.Reader + if bodyBytes != nil { + body = bytes.NewReader(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, method, urlString, body) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", scimContentType) + if payload != nil { + req.Header.Set("Content-Type", scimContentType) + } + token := string(provider.Token) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + slog.Debug("Sending SCIM request", + slog.String("method", method), + slog.String("url", urlString), + slog.String("provider_id", provider.ID), + ) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + + // Only retry on 429 to avoid masking other errors + if resp.StatusCode != http.StatusTooManyRequests || attempt == retryAttempts { + return resp, nil + } + + retryDelay := scimRetryDelay(resp.Header.Get("Retry-After"), attempt) + slog.WarnContext(ctx, "SCIM provider rate-limited, retrying", + slog.String("provider_id", provider.ID), + slog.String("method", method), + slog.String("url", urlString), + slog.Int("attempt", attempt), + slog.Duration("retry_after", retryDelay), + ) + + resp.Body.Close() + if err := utils.SleepWithContext(ctx, retryDelay); err != nil { + return nil, err + } + } + + return nil, fmt.Errorf("scim request retry attempts exceeded") +} + +func scimRetryDelay(retryAfter string, attempt int) time.Duration { + // Respect Retry-After when provided + if retryAfter != "" { + if seconds, err := strconv.Atoi(retryAfter); err == nil { + return time.Duration(seconds) * time.Second + } + if t, err := http.ParseTime(retryAfter); err == nil { + if delay := time.Until(t); delay > 0 { + return delay + } + } + } + + // Exponential backoff otherwise + maxDelay := 10 * time.Second + delay := 500 * time.Millisecond * (time.Duration(1) << (attempt - 1)) //nolint:gosec // attempt is bounded 1-3 + if delay > maxDelay { + return maxDelay + } + return delay +} + +func scimURL(endpoint, p string, queryParams map[string]string) (string, error) { + u, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("invalid scim endpoint: %w", err) + } + + u.Path = path.Join(strings.TrimRight(u.Path, "/"), p) + + q := u.Query() + for key, value := range queryParams { + q.Set(key, value) + } + u.RawQuery = q.Encode() + + return u.String(), nil +} + +func ensureScimStatus( + ctx context.Context, + resp *http.Response, + provider model.ScimServiceProvider, + allowedStatuses ...int) error { + for _, status := range allowedStatuses { + if resp.StatusCode == status { + return nil + } + } + + body := readScimErrorBody(resp.Body) + + slog.ErrorContext(ctx, "SCIM request failed", + slog.String("provider_id", provider.ID), + slog.String("method", resp.Request.Method), + slog.String("url", resp.Request.URL.String()), + slog.Int("status", resp.StatusCode), + slog.String("response_body", body), + ) + + return fmt.Errorf("scim request failed with status %d: %s", resp.StatusCode, body) +} + +func readScimErrorBody(body io.Reader) string { + payload, err := io.ReadAll(io.LimitReader(body, scimErrorBodyLimit)) + if err != nil { + return "" + } + return strings.TrimSpace(string(payload)) +} diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index c9223ae5..fbbf304e 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -3,7 +3,9 @@ package service import ( "context" "errors" + "time" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/common" @@ -151,6 +153,7 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input group.Name = input.Name group.FriendlyName = input.FriendlyName + group.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now())) err = tx. WithContext(ctx). @@ -214,6 +217,8 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u } // Save the updated group + group.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now())) + err = tx. WithContext(ctx). Save(&group). diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 1dacffdb..b66ae01b 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -426,6 +426,8 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd } } + user.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now())) + err = tx. WithContext(ctx). Save(&user). @@ -646,6 +648,16 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup return model.User{}, err } + // Update the UpdatedAt field for all affected groups + now := time.Now() + for _, group := range groups { + group.UpdatedAt = utils.Ptr(datatype.DateTime(now)) + err = tx.WithContext(ctx).Save(&group).Error + if err != nil { + return model.User{}, err + } + } + err = tx.Commit().Error if err != nil { return model.User{}, err diff --git a/backend/internal/utils/sleep_util.go b/backend/internal/utils/sleep_util.go new file mode 100644 index 00000000..67e3c29d --- /dev/null +++ b/backend/internal/utils/sleep_util.go @@ -0,0 +1,21 @@ +package utils + +import ( + "context" + "time" +) + +func SleepWithContext(ctx context.Context, delay time.Duration) error { + if delay <= 0 { + return nil + } + timer := time.NewTimer(delay) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/backend/resources/migrations/postgres/20251229173100_scim_service_providers.down.sql b/backend/resources/migrations/postgres/20251229173100_scim_service_providers.down.sql new file mode 100644 index 00000000..ad8294dc --- /dev/null +++ b/backend/resources/migrations/postgres/20251229173100_scim_service_providers.down.sql @@ -0,0 +1,3 @@ +DROP TABLE scim_service_providers; +ALTER TABLE users DROP COLUMN updated_at; +ALTER TABLE user_groups DROP COLUMN updated_at; \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20251229173100_scim_service_providers.up.sql b/backend/resources/migrations/postgres/20251229173100_scim_service_providers.up.sql new file mode 100644 index 00000000..11228043 --- /dev/null +++ b/backend/resources/migrations/postgres/20251229173100_scim_service_providers.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE scim_service_providers +( + id UUID PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + endpoint TEXT NOT NULL, + token TEXT NOT NULL, + last_synced_at TIMESTAMPTZ, + oidc_client_id TEXT NOT NULL REFERENCES oidc_clients (id) ON DELETE CASCADE +); + +ALTER TABLE users + ADD COLUMN updated_at TIMESTAMPTZ; + +ALTER TABLE user_groups + ADD COLUMN updated_at TIMESTAMPTZ; diff --git a/backend/resources/migrations/sqlite/20251229173100_scim_service_providers.down.sql b/backend/resources/migrations/sqlite/20251229173100_scim_service_providers.down.sql new file mode 100644 index 00000000..f8c3c4b6 --- /dev/null +++ b/backend/resources/migrations/sqlite/20251229173100_scim_service_providers.down.sql @@ -0,0 +1,9 @@ +PRAGMA foreign_keys=OFF; +BEGIN; + +DROP TABLE scim_service_providers; +ALTER TABLE users DROP COLUMN updated_at; +ALTER TABLE user_groups DROP COLUMN updated_at; + +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/backend/resources/migrations/sqlite/20251229173100_scim_service_providers.up.sql b/backend/resources/migrations/sqlite/20251229173100_scim_service_providers.up.sql new file mode 100644 index 00000000..92342f02 --- /dev/null +++ b/backend/resources/migrations/sqlite/20251229173100_scim_service_providers.up.sql @@ -0,0 +1,22 @@ +PRAGMA foreign_keys= OFF; +BEGIN; + +CREATE TABLE scim_service_providers +( + id TEXT PRIMARY KEY, + created_at DATETIME NOT NULL, + endpoint TEXT NOT NULL, + token TEXT NOT NULL, + last_synced_at DATETIME, + oidc_client_id TEXT NOT NULL, + FOREIGN KEY (oidc_client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE +); + +ALTER TABLE users + ADD COLUMN updated_at DATETIME; + +ALTER TABLE user_groups + ADD COLUMN updated_at DATETIME; + +COMMIT; +PRAGMA foreign_keys= ON; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 58f0e20e..59d641e6 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -484,5 +484,19 @@ "yes": "Yes", "no": "No", "restricted": "Restricted", + "scim_provisioning": "SCIM Provisioning", + "scim_provisioning_description": "SCIM provisioning allows you to automatically provision and deprovision users and groups from your OIDC client. Learn more in the docs.", + "scim_endpoint": "SCIM Endpoint", + "scim_token": "SCIM Token", + "last_successful_sync_at": "Last successful sync: {time}", + "scim_configuration_updated_successfully": "SCIM configuration updated successfully.", + "scim_enabled_successfully": "SCIM enabled successfully.", + "scim_disabled_successfully": "SCIM disabled successfully.", + "disable_scim_provisioning": "Disable SCIM Provisioning", + "disable_scim_provisioning_confirm_description": "Are you sure you want to disable SCIM provisioning for {clientName}? This will stop all automatic user and group provisioning and deprovisioning.", + "scim_sync_failed": "SCIM sync failed. Check the server logs for more information.", + "scim_sync_successful": "The SCIM sync has been completed successfully.", + "save_and_sync": "Save and Sync", + "scim_save_changes_description": "You have to save the changes before starting a SCIM sync. Do you want to save now?", "scopes": "Scopes" } diff --git a/frontend/src/lib/components/collapsible-card.svelte b/frontend/src/lib/components/collapsible-card.svelte index 94358651..60ee45c2 100644 --- a/frontend/src/lib/components/collapsible-card.svelte +++ b/frontend/src/lib/components/collapsible-card.svelte @@ -4,6 +4,7 @@ import { LucideChevronDown, type Icon as IconType } from '@lucide/svelte'; import { onMount, type Snippet } from 'svelte'; import { slide } from 'svelte/transition'; + import FormattedMessage from './formatted-message.svelte'; import { Button } from './ui/button'; import * as Card from './ui/card'; @@ -70,7 +71,7 @@ {title} {#if description} - {description} + {/if} {#if button} diff --git a/frontend/src/lib/services/oidc-service.ts b/frontend/src/lib/services/oidc-service.ts index 2563f2ee..fa4a7d09 100644 --- a/frontend/src/lib/services/oidc-service.ts +++ b/frontend/src/lib/services/oidc-service.ts @@ -10,6 +10,7 @@ import type { OidcClientWithAllowedUserGroupsCount, OidcDeviceCodeInfo } from '$lib/types/oidc.type'; +import type { ScimServiceProvider } from '$lib/types/scim.type'; import { cachedOidcClientLogo } from '$lib/utils/cached-image-util'; import APIService from './api-service'; @@ -127,6 +128,11 @@ class OidcService extends APIService { revokeOwnAuthorizedClient = async (clientId: string) => { await this.api.delete(`/oidc/users/me/authorized-clients/${clientId}`); }; + + getScimResourceProvider = async (clientId: string) => { + const res = await this.api.get(`/oidc/clients/${clientId}/scim-service-provider`); + return res.data as ScimServiceProvider; + }; } export default OidcService; diff --git a/frontend/src/lib/services/scim-service.ts b/frontend/src/lib/services/scim-service.ts new file mode 100644 index 00000000..a3bb8100 --- /dev/null +++ b/frontend/src/lib/services/scim-service.ts @@ -0,0 +1,27 @@ +import type { ScimServiceProvider, ScimServiceProviderCreate } from '$lib/types/scim.type'; +import APIService from './api-service'; + +class ScimService extends APIService { + syncServiceProvider = async (serviceProviderId: string) => { + return await this.api.post(`/scim/service-provider/${serviceProviderId}/sync`); + }; + + createServiceProvider = async (serviceProvider: ScimServiceProviderCreate) => { + return (await this.api.post('/scim/service-provider', serviceProvider)) + .data as ScimServiceProvider; + }; + + updateServiceProvider = async ( + serviceProviderId: string, + serviceProvider: ScimServiceProviderCreate + ) => { + return (await this.api.put(`/scim/service-provider/${serviceProviderId}`, serviceProvider)) + .data as ScimServiceProvider; + }; + + deleteServiceProvider = async (serviceProviderId: string) => { + await this.api.delete(`/scim/service-provider/${serviceProviderId}`); + }; +} + +export default ScimService; diff --git a/frontend/src/lib/types/scim.type.ts b/frontend/src/lib/types/scim.type.ts new file mode 100644 index 00000000..a3729671 --- /dev/null +++ b/frontend/src/lib/types/scim.type.ts @@ -0,0 +1,14 @@ +import type { OidcClientMetaData } from './oidc.type'; + +export type ScimServiceProvider = { + id: string; + endpoint: string; + token?: string; + lastSyncedAt?: string; + createdAt: string; + oidcClient: OidcClientMetaData; +}; + +export type ScimServiceProviderCreate = Pick & { + oidcClientId: string; +}; diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte index 4d364132..a9e41d05 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte @@ -10,8 +10,10 @@ import UserGroupSelection from '$lib/components/user-group-selection.svelte'; import { m } from '$lib/paraglide/messages'; import OidcService from '$lib/services/oidc-service'; + import ScimService from '$lib/services/scim-service'; import clientSecretStore from '$lib/stores/client-secret-store'; import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type'; + import type { ScimServiceProviderCreate } from '$lib/types/scim.type'; import { axiosErrorToast } from '$lib/utils/error-util'; import { LucideChevronLeft, LucideRefreshCcw } from '@lucide/svelte'; import { toast } from 'svelte-sonner'; @@ -19,16 +21,20 @@ import { backNavigate } from '../../users/navigate-back-util'; import OidcForm from '../oidc-client-form.svelte'; import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte'; + import ScimResourceProviderForm from './scim-resource-provider-form.svelte'; let { data } = $props(); let client = $state({ - ...data, - allowedUserGroupIds: data.allowedUserGroups.map((g) => g.id) + ...data.client, + allowedUserGroupIds: data.client.allowedUserGroups.map((g) => g.id) }); + + let scimServiceProvider = $state(data.scimServiceProvider); let showAllDetails = $state(false); let showPreview = $state(false); const oidcService = new OidcService(); + const scimService = new ScimService(); const backNavigation = backNavigate('/settings/admin/oidc-clients'); const setupDetails = $state({ @@ -149,6 +155,30 @@ }); } + async function saveScimServiceProvider(provider: ScimServiceProviderCreate | null) { + try { + if (!provider) { + await scimService.deleteServiceProvider(scimServiceProvider!.id); + scimServiceProvider = undefined; + toast.success(m.scim_disabled_successfully()); + return true; + } + let createdProvider; + if (scimServiceProvider) { + createdProvider = await scimService.updateServiceProvider(scimServiceProvider.id, provider); + toast.success(m.scim_configuration_updated_successfully()); + } else { + createdProvider = await scimService.createServiceProvider(provider); + toast.success(m.scim_enabled_successfully()); + } + scimServiceProvider = createdProvider; + return true; + } catch (e) { + axiosErrorToast(e); + return false; + } + } + beforeNavigate(() => { clientSecretStore.clear(); }); @@ -251,9 +281,22 @@
- +
+ + +
diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.ts b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.ts index 0e035b5b..ef8465b6 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.ts +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.ts @@ -3,5 +3,14 @@ import type { PageLoad } from './$types'; export const load: PageLoad = async ({ params }) => { const oidcService = new OidcService(); - return await oidcService.getClient(params.id); + + const client = await oidcService.getClient(params.id); + const scimServiceProvider = await oidcService + .getScimResourceProvider(params.id) + .then((p) => p) + .catch(() => undefined); + return { + client, + scimServiceProvider + }; }; diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/scim-resource-provider-form.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/scim-resource-provider-form.svelte new file mode 100644 index 00000000..e851a928 --- /dev/null +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/scim-resource-provider-form.svelte @@ -0,0 +1,144 @@ + + +
+
+
+ +
+
+ +
+
+
+ {#if existingProvider} +

+ {m.last_successful_sync_at({ + time: existingProvider.lastSyncedAt + ? new Date(existingProvider.lastSyncedAt).toLocaleString() + : m.never() + })} +

+ {/if} +
+ {#if existingProvider} + + + {/if} + +
+
+
diff --git a/tests/data.ts b/tests/data.ts index 236ffe0c..1c234ef2 100644 --- a/tests/data.ts +++ b/tests/data.ts @@ -56,6 +56,12 @@ export const oidcClients = { }, accessCodes: ['federated'] }, + scim: { + id: 'c46d2090-37a0-4f2b-8748-6aa53b0c1afa', + name: 'SCIM Client', + callbackUrl: 'http://scim.client/auth/callback', + secret: 'nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn' + }, pingvinShare: { name: 'Pingvin Share', callbackUrl: 'http://pingvin.share/auth/callback', diff --git a/tests/resources/export/database.json b/tests/resources/export/database.json index ce989cef..ed60f745 100644 --- a/tests/resources/export/database.json +++ b/tests/resources/export/database.json @@ -1,6 +1,6 @@ { "provider": "sqlite", - "version": 20251219000000, + "version": 20251229173100, "tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"], "tables": { "api_keys": [ @@ -122,12 +122,36 @@ "pkce_enabled": false, "requires_reauthentication": false, "secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe" + }, + { + "callback_urls": "WyJodHRwOi8vc2NpbWNsaWVudC9hdXRoL2NhbGxiYWNrIl0=", + "created_at": "2025-11-25T12:39:02Z", + "created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e", + "dark_image_type": null, + "id": "c46d2090-37a0-4f2b-8748-6aa53b0c1afa", + "image_type": null, + "is_group_restricted": true, + "is_public": false, + "launch_url": null, + "logout_callback_urls": "bnVsbA==", + "name": "SCIM Client", + "pkce_enabled": false, + "requires_reauthentication": false, + "secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK" } ], "oidc_clients_allowed_user_groups": [ { "oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018", "user_group_id": "adab18bf-f89d-4087-9ee1-70ff15b48211" + }, + { + "oidc_client_id": "c46d2090-37a0-4f2b-8748-6aa53b0c1afa", + "user_group_id": "adab18bf-f89d-4087-9ee1-70ff15b48211" + }, + { + "oidc_client_id": "c46d2090-37a0-4f2b-8748-6aa53b0c1afa", + "user_group_id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368" } ], "oidc_refresh_tokens": [ @@ -230,6 +254,7 @@ "user_groups": [ { "created_at": "2025-11-25T12:39:02Z", + "updated_at": null, "friendly_name": "Developers", "id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368", "ldap_id": null, @@ -237,6 +262,7 @@ }, { "created_at": "2025-11-25T12:39:02Z", + "updated_at": null, "friendly_name": "Designers", "id": "adab18bf-f89d-4087-9ee1-70ff15b48211", "ldap_id": null, @@ -260,6 +286,7 @@ "users": [ { "created_at": "2025-11-25T12:39:02Z", + "updated_at": null, "disabled": false, "display_name": "Tim Cook", "email": "tim.cook@test.com", @@ -273,6 +300,7 @@ }, { "created_at": "2025-11-25T12:39:02Z", + "updated_at": null, "disabled": false, "display_name": "Craig Federighi", "email": "craig.federighi@test.com", @@ -283,6 +311,20 @@ "ldap_id": null, "locale": null, "username": "craig" + }, + { + "created_at": "2025-11-25T12:39:02Z", + "updated_at": null, + "disabled": false, + "display_name": "Eddy Cue", + "email": "eddy.cue@test.com", + "first_name": "Eddy", + "id": "d9256384-98ad-49a7-bc58-99ad0b4dc23c", + "is_admin": false, + "last_name": "Cue", + "ldap_id": null, + "locale": null, + "username": "eddy" } ], "webauthn_credentials": [ diff --git a/tests/setup/docker-compose-postgres.yml b/tests/setup/docker-compose-postgres.yml index 7f03b6d1..5eede57a 100644 --- a/tests/setup/docker-compose-postgres.yml +++ b/tests/setup/docker-compose-postgres.yml @@ -4,6 +4,10 @@ services: extends: file: docker-compose.yml service: lldap + scim-test-server: + extends: + file: docker-compose.yml + service: scim-test-server postgres: image: postgres:17 environment: diff --git a/tests/setup/docker-compose-s3.yml b/tests/setup/docker-compose-s3.yml index 1fdc511d..533afee9 100644 --- a/tests/setup/docker-compose-s3.yml +++ b/tests/setup/docker-compose-s3.yml @@ -3,7 +3,10 @@ services: extends: file: docker-compose.yml service: lldap - + scim-test-server: + extends: + file: docker-compose.yml + service: scim-test-server localstack-s3: image: localstack/localstack:s3-latest healthcheck: @@ -11,7 +14,6 @@ services: interval: 1s timeout: 3s retries: 10 - create-bucket: image: amazon/aws-cli:latest environment: @@ -22,7 +24,6 @@ services: localstack-s3: condition: service_healthy entrypoint: "aws --endpoint-url=http://localstack-s3:4566 s3 mb s3://pocket-id-test" - pocket-id: extends: file: docker-compose.yml diff --git a/tests/setup/docker-compose.yml b/tests/setup/docker-compose.yml index d4e65c14..1b36fee9 100644 --- a/tests/setup/docker-compose.yml +++ b/tests/setup/docker-compose.yml @@ -8,6 +8,10 @@ services: - LLDAP_JWT_SECRET=secret - LLDAP_LDAP_USER_PASS=admin_password - LLDAP_LDAP_BASE_DN=dc=pocket-id,dc=org + scim-test-server: + image: ghcr.io/pocket-id/scim-test-server:latest + ports: + - "18123:8080" pocket-id: image: pocket-id:test ports: diff --git a/tests/specs/apps-dashboard.spec.ts b/tests/specs/apps-dashboard.spec.ts index 56eeeac1..818101fb 100644 --- a/tests/specs/apps-dashboard.spec.ts +++ b/tests/specs/apps-dashboard.spec.ts @@ -11,7 +11,7 @@ test('Dashboard shows all clients in the correct order', async ({ page }) => { await page.goto('/settings/apps'); - await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(4); + await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(5); // Should be first const card1 = page.getByTestId('authorized-oidc-client-card').first(); @@ -32,7 +32,7 @@ test.describe('Dashboard shows only clients where user has access', () => { const cards = page.getByTestId('authorized-oidc-client-card'); - await expect(cards).toHaveCount(3); + await expect(cards).toHaveCount(4); const cardTexts = await cards.allTextContents(); expect(cardTexts.some((text) => text.includes(notVisibleClient.name))).toBe(false); @@ -40,7 +40,7 @@ test.describe('Dashboard shows only clients where user has access', () => { test('User can see all clients', async ({ page }) => { await page.goto('/settings/apps'); const cards = page.getByTestId('authorized-oidc-client-card'); - await expect(cards).toHaveCount(4); + await expect(cards).toHaveCount(5); }); }); diff --git a/tests/specs/scim.spec.ts b/tests/specs/scim.spec.ts new file mode 100644 index 00000000..c4711c23 --- /dev/null +++ b/tests/specs/scim.spec.ts @@ -0,0 +1,172 @@ +import test, { expect, type Page } from '@playwright/test'; +import { cleanupBackend, cleanupScimServiceProvider } from 'utils/cleanup.util'; +import { oidcClients, userGroups, users } from '../data'; + +async function configureOidcClient(page: Page) { + await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`); + + await page.getByRole('button', { name: 'Expand card' }).nth(1).click(); + + await page + .getByLabel('SCIM Endpoint') + .fill(process.env.SCIM_SERVICE_PROVIDER_URL_INTERNAL || 'http://scim.provider/api'); + + await page.getByRole('button', { name: 'Enable' }).click(); +} + +async function syncScimServiceProvider(page: Page) { + await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`); + + await page.getByRole('button', { name: 'Sync now' }).click(); + await page.waitForSelector('[data-type="success"]'); +} + +test.beforeEach(async () => { + await cleanupBackend({ skipLdapSetup: true }); +}); + +test.describe('SCIM Configuration', () => { + test('Enable SCIM for OIDC client', async ({ page }) => { + await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`); + + await page.getByRole('button', { name: 'Expand card' }).nth(1).click(); + + await page.getByLabel('SCIM Endpoint').fill('http://scim.provider/api'); + await page.getByLabel('SCIM Token').fill('supersecrettoken'); + + await page.getByRole('button', { name: 'Enable' }).click(); + + await expect(page.locator('[data-type="success"]')).toHaveText('SCIM enabled successfully.'); + + await page.reload(); + + await expect(page.getByLabel('SCIM Endpoint')).toHaveValue('http://scim.provider/api'); + await expect(page.getByLabel('SCIM Token')).toHaveValue('supersecrettoken'); + }); + + test('Update SCIM of OIDC client', async ({ page }) => { + await configureOidcClient(page); + + await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`); + + await page.getByLabel('SCIM Endpoint').fill('http://new.scim.provider/api'); + await page.getByLabel('SCIM Token').fill('evenmoresecrettoken'); + + await page.getByRole('button', { name: 'Save' }).nth(1).click(); + + await expect(page.locator('[data-type="success"]')).toHaveText( + 'SCIM configuration updated successfully.' + ); + + await page.reload(); + + await expect(page.getByLabel('SCIM Endpoint')).toHaveValue('http://new.scim.provider/api'); + await expect(page.getByLabel('SCIM Token')).toHaveValue('evenmoresecrettoken'); + }); + + test('Disable SCIM of OIDC client', async ({ page }) => { + await configureOidcClient(page); + + await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`); + + await page.getByRole('button', { name: 'Disable' }).click(); + await page.getByRole('button', { name: 'Disable' }).nth(1).click(); + + await expect(page.locator('[data-type="success"]')).toHaveText('SCIM disabled successfully.'); + + await page.reload(); + + await expect(page.getByRole('button', { name: 'Enable' })).toBeVisible(); + await expect(page.getByLabel('SCIM Endpoint')).toHaveValue(''); + await expect(page.getByLabel('SCIM Token')).toHaveValue(''); + }); +}); + +test.describe('SCIM Sync', () => { + test.skip( + !process.env.SCIM_SERVICE_PROVIDER_URL || !process.env.SCIM_SERVICE_PROVIDER_URL_INTERNAL, + 'Skipping SCIM Sync tests because SCIM_SERVICE_PROVIDER_URL or SCIM_SERVICE_PROVIDER_URL_INTERNAL is not set' + ); + + test.beforeEach(async ({ page }) => { + await Promise.all([configureOidcClient(page), cleanupScimServiceProvider()]); + }); + + test('Sync client', async ({ page }) => { + await syncScimServiceProvider(page); + + const scimUsers = await getScimResources('Users'); + await expect(scimUsers.length).toBe(2); + + const groups = await getScimResources('Groups'); + await expect(groups.length).toBe(2); + + const timUser = scimUsers.find((u: any) => u.userName === 'tim'); + await expect(timUser).toBeDefined(); + await expect(timUser).toMatchObject({ + externalId: users.tim.id, + emails: [ + { + value: users.tim.email, + primary: true + } + ], + name: { + givenName: users.tim.firstname, + familyName: users.tim.lastname + }, + displayName: users.tim.displayName, + active: true + }); + }); + + test('Remove allowed group and sync', async ({ page }) => { + await syncScimServiceProvider(page); + + await page.getByRole('button', { name: 'Expand card' }).first().click(); + + await page + .getByRole('row', { name: userGroups.developers.name }) + .getByRole('cell') + .first() + .click(); + + await page.getByRole('button', { name: 'Save' }).nth(1).click(); + + await syncScimServiceProvider(page); + + const scimUsers = await getScimResources('Users'); + await expect(scimUsers.length).toBe(1); + await expect(scimUsers.find((u: any) => u.userName === users.tim.username)).toBeDefined(); + + const scimGroups = await getScimResources('Groups'); + await expect(scimGroups.length).toBe(1); + await expect( + scimGroups.find((g: any) => g.displayName === userGroups.designers.friendlyName) + ).toBeDefined(); + }); + + test('Remove group restrictions and sync', async ({ page }) => { + await syncScimServiceProvider(page); + + await page.getByRole('button', { name: 'Expand card' }).first().click(); + + await page.getByRole('button', { name: 'Unrestrict' }).click(); + await page.getByRole('button', { name: 'Unrestrict' }).nth(1).click(); + + await syncScimServiceProvider(page); + + const scimUsers = await getScimResources('Users'); + await expect(scimUsers.length).toBe(3); + + const scimGroups = await getScimResources('Groups'); + await expect(scimGroups.length).toBe(2); + }); +}); + +async function getScimResources(resourceType: 'Users' | 'Groups') { + const response = await fetch(`${process.env.SCIM_SERVICE_PROVIDER_URL}/${resourceType}`).then( + (res) => res.json() + ); + return response['Resources']; +} diff --git a/tests/utils/cleanup.util.ts b/tests/utils/cleanup.util.ts index b0cc1f28..932d139c 100644 --- a/tests/utils/cleanup.util.ts +++ b/tests/utils/cleanup.util.ts @@ -19,3 +19,8 @@ export async function cleanupBackend({ skipSeed = false, skipLdapSetup = false } throw new Error(`Failed to reset backend: ${response.status} ${response.statusText}`); } } + +export async function cleanupScimServiceProvider() { + if (!process.env.SCIM_SERVICE_PROVIDER_URL) return; + await fetch(`${process.env.SCIM_SERVICE_PROVIDER_URL}/reset`, { method: 'POST' }); +}