diff --git a/idp/dex/connector.go b/idp/dex/connector.go new file mode 100644 index 000000000..cad682141 --- /dev/null +++ b/idp/dex/connector.go @@ -0,0 +1,356 @@ +// Package dex provides an embedded Dex OIDC identity provider. +package dex + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/dexidp/dex/storage" +) + +// ConnectorConfig represents the configuration for an identity provider connector +type ConnectorConfig struct { + // ID is the unique identifier for the connector + ID string + // Name is a human-readable name for the connector + Name string + // Type is the connector type (oidc, google, microsoft) + Type string + // Issuer is the OIDC issuer URL (for OIDC-based connectors) + Issuer string + // ClientID is the OAuth2 client ID + ClientID string + // ClientSecret is the OAuth2 client secret + ClientSecret string + // RedirectURI is the OAuth2 redirect URI + RedirectURI string +} + +// CreateConnector creates a new connector in Dex storage. +// It maps the connector config to the appropriate Dex connector type and configuration. +func (p *Provider) CreateConnector(ctx context.Context, cfg *ConnectorConfig) (*ConnectorConfig, error) { + // Fill in the redirect URI if not provided + if cfg.RedirectURI == "" { + cfg.RedirectURI = p.GetRedirectURI() + } + + storageConn, err := p.buildStorageConnector(cfg) + if err != nil { + return nil, fmt.Errorf("failed to build connector: %w", err) + } + + if err := p.storage.CreateConnector(ctx, storageConn); err != nil { + return nil, fmt.Errorf("failed to create connector: %w", err) + } + + p.logger.Info("connector created", "id", cfg.ID, "type", cfg.Type) + return cfg, nil +} + +// GetConnector retrieves a connector by ID from Dex storage. +func (p *Provider) GetConnector(ctx context.Context, id string) (*ConnectorConfig, error) { + conn, err := p.storage.GetConnector(ctx, id) + if err != nil { + if err == storage.ErrNotFound { + return nil, err + } + return nil, fmt.Errorf("failed to get connector: %w", err) + } + + return p.parseStorageConnector(conn) +} + +// ListConnectors returns all connectors from Dex storage (excluding the local connector). +func (p *Provider) ListConnectors(ctx context.Context) ([]*ConnectorConfig, error) { + connectors, err := p.storage.ListConnectors(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list connectors: %w", err) + } + + result := make([]*ConnectorConfig, 0, len(connectors)) + for _, conn := range connectors { + // Skip the local password connector + if conn.ID == "local" && conn.Type == "local" { + continue + } + + cfg, err := p.parseStorageConnector(conn) + if err != nil { + p.logger.Warn("failed to parse connector", "id", conn.ID, "error", err) + continue + } + result = append(result, cfg) + } + + return result, nil +} + +// UpdateConnector updates an existing connector in Dex storage. +// It merges incoming updates with existing values to prevent data loss on partial updates. +func (p *Provider) UpdateConnector(ctx context.Context, cfg *ConnectorConfig) error { + if err := p.storage.UpdateConnector(ctx, cfg.ID, func(old storage.Connector) (storage.Connector, error) { + oldCfg, err := p.parseStorageConnector(old) + if err != nil { + return storage.Connector{}, fmt.Errorf("failed to parse existing connector: %w", err) + } + + mergeConnectorConfig(cfg, oldCfg) + + storageConn, err := p.buildStorageConnector(cfg) + if err != nil { + return storage.Connector{}, fmt.Errorf("failed to build connector: %w", err) + } + return storageConn, nil + }); err != nil { + return fmt.Errorf("failed to update connector: %w", err) + } + + p.logger.Info("connector updated", "id", cfg.ID, "type", cfg.Type) + return nil +} + +// mergeConnectorConfig preserves existing values for empty fields in the update. +func mergeConnectorConfig(cfg, oldCfg *ConnectorConfig) { + if cfg.ClientSecret == "" { + cfg.ClientSecret = oldCfg.ClientSecret + } + if cfg.RedirectURI == "" { + cfg.RedirectURI = oldCfg.RedirectURI + } + if cfg.Issuer == "" && cfg.Type == oldCfg.Type { + cfg.Issuer = oldCfg.Issuer + } + if cfg.ClientID == "" { + cfg.ClientID = oldCfg.ClientID + } + if cfg.Name == "" { + cfg.Name = oldCfg.Name + } +} + +// DeleteConnector removes a connector from Dex storage. +func (p *Provider) DeleteConnector(ctx context.Context, id string) error { + // Prevent deletion of the local connector + if id == "local" { + return fmt.Errorf("cannot delete the local password connector") + } + + if err := p.storage.DeleteConnector(ctx, id); err != nil { + return fmt.Errorf("failed to delete connector: %w", err) + } + + p.logger.Info("connector deleted", "id", id) + return nil +} + +// GetRedirectURI returns the default redirect URI for connectors. +func (p *Provider) GetRedirectURI() string { + if p.config == nil { + return "" + } + issuer := strings.TrimSuffix(p.config.Issuer, "/") + if !strings.HasSuffix(issuer, "/oauth2") { + issuer += "/oauth2" + } + return issuer + "/callback" +} + +// buildStorageConnector creates a storage.Connector from ConnectorConfig. +// It handles the type-specific configuration for each connector type. +func (p *Provider) buildStorageConnector(cfg *ConnectorConfig) (storage.Connector, error) { + redirectURI := p.resolveRedirectURI(cfg.RedirectURI) + + var dexType string + var configData []byte + var err error + + switch cfg.Type { + case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak": + dexType = "oidc" + configData, err = buildOIDCConnectorConfig(cfg, redirectURI) + case "google": + dexType = "google" + configData, err = buildOAuth2ConnectorConfig(cfg, redirectURI) + case "microsoft": + dexType = "microsoft" + configData, err = buildOAuth2ConnectorConfig(cfg, redirectURI) + default: + return storage.Connector{}, fmt.Errorf("unsupported connector type: %s", cfg.Type) + } + if err != nil { + return storage.Connector{}, err + } + + return storage.Connector{ID: cfg.ID, Type: dexType, Name: cfg.Name, Config: configData}, nil +} + +// resolveRedirectURI returns the redirect URI, using a default if not provided +func (p *Provider) resolveRedirectURI(redirectURI string) string { + if redirectURI != "" || p.config == nil { + return redirectURI + } + issuer := strings.TrimSuffix(p.config.Issuer, "/") + if !strings.HasSuffix(issuer, "/oauth2") { + issuer += "/oauth2" + } + return issuer + "/callback" +} + +// buildOIDCConnectorConfig creates config for OIDC-based connectors +func buildOIDCConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte, error) { + oidcConfig := map[string]interface{}{ + "issuer": cfg.Issuer, + "clientID": cfg.ClientID, + "clientSecret": cfg.ClientSecret, + "redirectURI": redirectURI, + "scopes": []string{"openid", "profile", "email"}, + "insecureEnableGroups": true, + //some providers don't return email verified, so we need to skip it if not present (e.g., Entra, Okta, Duo) + "insecureSkipEmailVerified": true, + } + switch cfg.Type { + case "zitadel": + oidcConfig["getUserInfo"] = true + case "entra": + oidcConfig["claimMapping"] = map[string]string{"email": "preferred_username"} + case "okta": + oidcConfig["scopes"] = []string{"openid", "profile", "email", "groups"} + case "pocketid": + oidcConfig["scopes"] = []string{"openid", "profile", "email", "groups"} + } + return encodeConnectorConfig(oidcConfig) +} + +// buildOAuth2ConnectorConfig creates config for OAuth2 connectors (google, microsoft) +func buildOAuth2ConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte, error) { + return encodeConnectorConfig(map[string]interface{}{ + "clientID": cfg.ClientID, + "clientSecret": cfg.ClientSecret, + "redirectURI": redirectURI, + }) +} + +// parseStorageConnector converts a storage.Connector back to ConnectorConfig. +// It infers the original identity provider type from the Dex connector type and ID. +func (p *Provider) parseStorageConnector(conn storage.Connector) (*ConnectorConfig, error) { + cfg := &ConnectorConfig{ + ID: conn.ID, + Name: conn.Name, + } + + if len(conn.Config) == 0 { + cfg.Type = conn.Type + return cfg, nil + } + + var configMap map[string]interface{} + if err := decodeConnectorConfig(conn.Config, &configMap); err != nil { + return nil, fmt.Errorf("failed to parse connector config: %w", err) + } + + // Extract common fields + if v, ok := configMap["clientID"].(string); ok { + cfg.ClientID = v + } + if v, ok := configMap["clientSecret"].(string); ok { + cfg.ClientSecret = v + } + if v, ok := configMap["redirectURI"].(string); ok { + cfg.RedirectURI = v + } + if v, ok := configMap["issuer"].(string); ok { + cfg.Issuer = v + } + + // Infer the original identity provider type from Dex connector type and ID + cfg.Type = inferIdentityProviderType(conn.Type, conn.ID, configMap) + + return cfg, nil +} + +// inferIdentityProviderType determines the original identity provider type +// based on the Dex connector type, connector ID, and configuration. +func inferIdentityProviderType(dexType, connectorID string, _ map[string]interface{}) string { + if dexType != "oidc" { + return dexType + } + return inferOIDCProviderType(connectorID) +} + +// inferOIDCProviderType infers the specific OIDC provider from connector ID +func inferOIDCProviderType(connectorID string) string { + connectorIDLower := strings.ToLower(connectorID) + for _, provider := range []string{"pocketid", "zitadel", "entra", "okta", "authentik", "keycloak"} { + if strings.Contains(connectorIDLower, provider) { + return provider + } + } + return "oidc" +} + +// encodeConnectorConfig serializes connector config to JSON bytes. +func encodeConnectorConfig(config map[string]interface{}) ([]byte, error) { + return json.Marshal(config) +} + +// decodeConnectorConfig deserializes connector config from JSON bytes. +func decodeConnectorConfig(data []byte, v interface{}) error { + return json.Unmarshal(data, v) +} + +// ensureLocalConnector creates a local (password) connector if it doesn't exist +func ensureLocalConnector(ctx context.Context, stor storage.Storage) error { + // Check specifically for the local connector + _, err := stor.GetConnector(ctx, "local") + if err == nil { + // Local connector already exists + return nil + } + if !errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("failed to get local connector: %w", err) + } + + // Create a local connector for password authentication + localConnector := storage.Connector{ + ID: "local", + Type: "local", + Name: "Email", + } + + if err := stor.CreateConnector(ctx, localConnector); err != nil { + return fmt.Errorf("failed to create local connector: %w", err) + } + + return nil +} + +// ensureStaticConnectors creates or updates static connectors in storage +func ensureStaticConnectors(ctx context.Context, stor storage.Storage, connectors []Connector) error { + for _, conn := range connectors { + storConn, err := conn.ToStorageConnector() + if err != nil { + return fmt.Errorf("failed to convert connector %s: %w", conn.ID, err) + } + _, err = stor.GetConnector(ctx, conn.ID) + if err == storage.ErrNotFound { + if err := stor.CreateConnector(ctx, storConn); err != nil { + return fmt.Errorf("failed to create connector %s: %w", conn.ID, err) + } + continue + } + if err != nil { + return fmt.Errorf("failed to get connector %s: %w", conn.ID, err) + } + if err := stor.UpdateConnector(ctx, conn.ID, func(old storage.Connector) (storage.Connector, error) { + old.Name = storConn.Name + old.Config = storConn.Config + return old, nil + }); err != nil { + return fmt.Errorf("failed to update connector %s: %w", conn.ID, err) + } + } + return nil +} diff --git a/idp/dex/provider.go b/idp/dex/provider.go index 6625d9eaf..6c608dbf5 100644 --- a/idp/dex/provider.go +++ b/idp/dex/provider.go @@ -4,7 +4,6 @@ package dex import ( "context" "encoding/base64" - "encoding/json" "errors" "fmt" "log/slog" @@ -245,34 +244,6 @@ func ensureStaticClients(ctx context.Context, stor storage.Storage, clients []st return nil } -// ensureStaticConnectors creates or updates static connectors in storage -func ensureStaticConnectors(ctx context.Context, stor storage.Storage, connectors []Connector) error { - for _, conn := range connectors { - storConn, err := conn.ToStorageConnector() - if err != nil { - return fmt.Errorf("failed to convert connector %s: %w", conn.ID, err) - } - _, err = stor.GetConnector(ctx, conn.ID) - if errors.Is(err, storage.ErrNotFound) { - if err := stor.CreateConnector(ctx, storConn); err != nil { - return fmt.Errorf("failed to create connector %s: %w", conn.ID, err) - } - continue - } - if err != nil { - return fmt.Errorf("failed to get connector %s: %w", conn.ID, err) - } - if err := stor.UpdateConnector(ctx, conn.ID, func(old storage.Connector) (storage.Connector, error) { - old.Name = storConn.Name - old.Config = storConn.Config - return old, nil - }); err != nil { - return fmt.Errorf("failed to update connector %s: %w", conn.ID, err) - } - } - return nil -} - // buildDexConfig creates a server.Config with defaults applied func buildDexConfig(yamlConfig *YAMLConfig, stor storage.Storage, logger *slog.Logger) server.Config { cfg := yamlConfig.ToServerConfig(stor, logger) @@ -613,294 +584,37 @@ func (p *Provider) ListUsers(ctx context.Context) ([]storage.Password, error) { return p.storage.ListPasswords(ctx) } -// ensureLocalConnector creates a local (password) connector if none exists -func ensureLocalConnector(ctx context.Context, stor storage.Storage) error { - connectors, err := stor.ListConnectors(ctx) +// UpdateUserPassword updates the password for a user identified by userID. +// The userID can be either an encoded Dex ID (base64 protobuf) or a raw UUID. +// It verifies the current password before updating. +func (p *Provider) UpdateUserPassword(ctx context.Context, userID string, oldPassword, newPassword string) error { + // Get the user by ID to find their email + user, err := p.GetUserByID(ctx, userID) if err != nil { - return fmt.Errorf("failed to list connectors: %w", err) + return fmt.Errorf("failed to get user: %w", err) } - // If any connector exists, we're good - if len(connectors) > 0 { - return nil + // Verify old password + if err := bcrypt.CompareHashAndPassword(user.Hash, []byte(oldPassword)); err != nil { + return fmt.Errorf("current password is incorrect") } - // Create a local connector for password authentication - localConnector := storage.Connector{ - ID: "local", - Type: "local", - Name: "Email", - } - - if err := stor.CreateConnector(ctx, localConnector); err != nil { - return fmt.Errorf("failed to create local connector: %w", err) - } - - return nil -} - -// ConnectorConfig represents the configuration for an identity provider connector -type ConnectorConfig struct { - // ID is the unique identifier for the connector - ID string - // Name is a human-readable name for the connector - Name string - // Type is the connector type (oidc, google, microsoft) - Type string - // Issuer is the OIDC issuer URL (for OIDC-based connectors) - Issuer string - // ClientID is the OAuth2 client ID - ClientID string - // ClientSecret is the OAuth2 client secret - ClientSecret string - // RedirectURI is the OAuth2 redirect URI - RedirectURI string -} - -// CreateConnector creates a new connector in Dex storage. -// It maps the connector config to the appropriate Dex connector type and configuration. -func (p *Provider) CreateConnector(ctx context.Context, cfg *ConnectorConfig) (*ConnectorConfig, error) { - // Fill in the redirect URI if not provided - if cfg.RedirectURI == "" { - cfg.RedirectURI = p.GetRedirectURI() - } - - storageConn, err := p.buildStorageConnector(cfg) + // Hash the new password + newHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { - return nil, fmt.Errorf("failed to build connector: %w", err) + return fmt.Errorf("failed to hash new password: %w", err) } - if err := p.storage.CreateConnector(ctx, storageConn); err != nil { - return nil, fmt.Errorf("failed to create connector: %w", err) - } - - p.logger.Info("connector created", "id", cfg.ID, "type", cfg.Type) - return cfg, nil -} - -// GetConnector retrieves a connector by ID from Dex storage. -func (p *Provider) GetConnector(ctx context.Context, id string) (*ConnectorConfig, error) { - conn, err := p.storage.GetConnector(ctx, id) - if err != nil { - if err == storage.ErrNotFound { - return nil, err - } - return nil, fmt.Errorf("failed to get connector: %w", err) - } - - return p.parseStorageConnector(conn) -} - -// ListConnectors returns all connectors from Dex storage (excluding the local connector). -func (p *Provider) ListConnectors(ctx context.Context) ([]*ConnectorConfig, error) { - connectors, err := p.storage.ListConnectors(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list connectors: %w", err) - } - - result := make([]*ConnectorConfig, 0, len(connectors)) - for _, conn := range connectors { - // Skip the local password connector - if conn.ID == "local" && conn.Type == "local" { - continue - } - - cfg, err := p.parseStorageConnector(conn) - if err != nil { - p.logger.Warn("failed to parse connector", "id", conn.ID, "error", err) - continue - } - result = append(result, cfg) - } - - return result, nil -} - -// UpdateConnector updates an existing connector in Dex storage. -func (p *Provider) UpdateConnector(ctx context.Context, cfg *ConnectorConfig) error { - storageConn, err := p.buildStorageConnector(cfg) - if err != nil { - return fmt.Errorf("failed to build connector: %w", err) - } - - if err := p.storage.UpdateConnector(ctx, cfg.ID, func(old storage.Connector) (storage.Connector, error) { - return storageConn, nil - }); err != nil { - return fmt.Errorf("failed to update connector: %w", err) - } - - p.logger.Info("connector updated", "id", cfg.ID, "type", cfg.Type) - return nil -} - -// DeleteConnector removes a connector from Dex storage. -func (p *Provider) DeleteConnector(ctx context.Context, id string) error { - // Prevent deletion of the local connector - if id == "local" { - return fmt.Errorf("cannot delete the local password connector") - } - - if err := p.storage.DeleteConnector(ctx, id); err != nil { - return fmt.Errorf("failed to delete connector: %w", err) - } - - p.logger.Info("connector deleted", "id", id) - return nil -} - -// buildStorageConnector creates a storage.Connector from ConnectorConfig. -// It handles the type-specific configuration for each connector type. -func (p *Provider) buildStorageConnector(cfg *ConnectorConfig) (storage.Connector, error) { - redirectURI := p.resolveRedirectURI(cfg.RedirectURI) - - var dexType string - var configData []byte - var err error - - switch cfg.Type { - case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak": - dexType = "oidc" - configData, err = buildOIDCConnectorConfig(cfg, redirectURI) - case "google": - dexType = "google" - configData, err = buildOAuth2ConnectorConfig(cfg, redirectURI) - case "microsoft": - dexType = "microsoft" - configData, err = buildOAuth2ConnectorConfig(cfg, redirectURI) - default: - return storage.Connector{}, fmt.Errorf("unsupported connector type: %s", cfg.Type) - } - if err != nil { - return storage.Connector{}, err - } - - return storage.Connector{ID: cfg.ID, Type: dexType, Name: cfg.Name, Config: configData}, nil -} - -// resolveRedirectURI returns the redirect URI, using a default if not provided -func (p *Provider) resolveRedirectURI(redirectURI string) string { - if redirectURI != "" || p.config == nil { - return redirectURI - } - issuer := strings.TrimSuffix(p.config.Issuer, "/") - if !strings.HasSuffix(issuer, "/oauth2") { - issuer += "/oauth2" - } - return issuer + "/callback" -} - -// buildOIDCConnectorConfig creates config for OIDC-based connectors -func buildOIDCConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte, error) { - oidcConfig := map[string]interface{}{ - "issuer": cfg.Issuer, - "clientID": cfg.ClientID, - "clientSecret": cfg.ClientSecret, - "redirectURI": redirectURI, - "scopes": []string{"openid", "profile", "email"}, - "insecureEnableGroups": true, - //some providers don't return email verified, so we need to skip it if not present (e.g., Entra, Okta, Duo) - "insecureSkipEmailVerified": true, - } - switch cfg.Type { - case "zitadel": - oidcConfig["getUserInfo"] = true - case "entra": - oidcConfig["claimMapping"] = map[string]string{"email": "preferred_username"} - case "okta": - oidcConfig["scopes"] = []string{"openid", "profile", "email", "groups"} - case "pocketid": - oidcConfig["scopes"] = []string{"openid", "profile", "email", "groups"} - } - return encodeConnectorConfig(oidcConfig) -} - -// buildOAuth2ConnectorConfig creates config for OAuth2 connectors (google, microsoft) -func buildOAuth2ConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte, error) { - return encodeConnectorConfig(map[string]interface{}{ - "clientID": cfg.ClientID, - "clientSecret": cfg.ClientSecret, - "redirectURI": redirectURI, + // Update the password in storage + err = p.storage.UpdatePassword(ctx, user.Email, func(old storage.Password) (storage.Password, error) { + old.Hash = newHash + return old, nil }) -} - -// parseStorageConnector converts a storage.Connector back to ConnectorConfig. -// It infers the original identity provider type from the Dex connector type and ID. -func (p *Provider) parseStorageConnector(conn storage.Connector) (*ConnectorConfig, error) { - cfg := &ConnectorConfig{ - ID: conn.ID, - Name: conn.Name, + if err != nil { + return fmt.Errorf("failed to update password: %w", err) } - if len(conn.Config) == 0 { - cfg.Type = conn.Type - return cfg, nil - } - - var configMap map[string]interface{} - if err := decodeConnectorConfig(conn.Config, &configMap); err != nil { - return nil, fmt.Errorf("failed to parse connector config: %w", err) - } - - // Extract common fields - if v, ok := configMap["clientID"].(string); ok { - cfg.ClientID = v - } - if v, ok := configMap["clientSecret"].(string); ok { - cfg.ClientSecret = v - } - if v, ok := configMap["redirectURI"].(string); ok { - cfg.RedirectURI = v - } - if v, ok := configMap["issuer"].(string); ok { - cfg.Issuer = v - } - - // Infer the original identity provider type from Dex connector type and ID - cfg.Type = inferIdentityProviderType(conn.Type, conn.ID, configMap) - - return cfg, nil -} - -// inferIdentityProviderType determines the original identity provider type -// based on the Dex connector type, connector ID, and configuration. -func inferIdentityProviderType(dexType, connectorID string, _ map[string]interface{}) string { - if dexType != "oidc" { - return dexType - } - return inferOIDCProviderType(connectorID) -} - -// inferOIDCProviderType infers the specific OIDC provider from connector ID -func inferOIDCProviderType(connectorID string) string { - connectorIDLower := strings.ToLower(connectorID) - for _, provider := range []string{"pocketid", "zitadel", "entra", "okta", "authentik", "keycloak"} { - if strings.Contains(connectorIDLower, provider) { - return provider - } - } - return "oidc" -} - -// encodeConnectorConfig serializes connector config to JSON bytes. -func encodeConnectorConfig(config map[string]interface{}) ([]byte, error) { - return json.Marshal(config) -} - -// decodeConnectorConfig deserializes connector config from JSON bytes. -func decodeConnectorConfig(data []byte, v interface{}) error { - return json.Unmarshal(data, v) -} - -// GetRedirectURI returns the default redirect URI for connectors. -func (p *Provider) GetRedirectURI() string { - if p.config == nil { - return "" - } - issuer := strings.TrimSuffix(p.config.Issuer, "/") - if !strings.HasSuffix(issuer, "/oauth2") { - issuer += "/oauth2" - } - return issuer + "/callback" + return nil } // GetIssuer returns the OIDC issuer URL. diff --git a/management/server/account/manager.go b/management/server/account/manager.go index f925af4ec..11af67358 100644 --- a/management/server/account/manager.go +++ b/management/server/account/manager.go @@ -32,6 +32,7 @@ type Manager interface { CreateUser(ctx context.Context, accountID, initiatorUserID string, key *types.UserInfo) (*types.UserInfo, error) DeleteUser(ctx context.Context, accountID, initiatorUserID string, targetUserID string) error DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error + UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error InviteUser(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error ApproveUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error) RejectUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go index ae8e46db9..e9eaa644b 100644 --- a/management/server/activity/codes.go +++ b/management/server/activity/codes.go @@ -195,7 +195,9 @@ const ( DNSRecordUpdated Activity = 100 DNSRecordDeleted Activity = 101 - JobCreatedByUser Activity = 102 + JobCreatedByUser Activity = 102 + + UserPasswordChanged Activity = 103 AccountDeleted Activity = 99999 ) @@ -323,6 +325,8 @@ var activityMap = map[Activity]Code{ DNSRecordDeleted: {"DNS zone record deleted", "dns.zone.record.delete"}, JobCreatedByUser: {"Create Job for peer", "peer.job.create"}, + + UserPasswordChanged: {"User password changed", "user.password.change"}, } // StringCode returns a string code of the activity diff --git a/management/server/http/handlers/users/users_handler.go b/management/server/http/handlers/users/users_handler.go index 7669d7404..40ad585d2 100644 --- a/management/server/http/handlers/users/users_handler.go +++ b/management/server/http/handlers/users/users_handler.go @@ -33,6 +33,7 @@ func AddEndpoints(accountManager account.Manager, router *mux.Router) { router.HandleFunc("/users/{userId}/invite", userHandler.inviteUser).Methods("POST", "OPTIONS") router.HandleFunc("/users/{userId}/approve", userHandler.approveUser).Methods("POST", "OPTIONS") router.HandleFunc("/users/{userId}/reject", userHandler.rejectUser).Methods("DELETE", "OPTIONS") + router.HandleFunc("/users/{userId}/password", userHandler.changePassword).Methods("PUT", "OPTIONS") addUsersTokensEndpoint(accountManager, router) } @@ -410,3 +411,46 @@ func (h *handler) rejectUser(w http.ResponseWriter, r *http.Request) { util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) } + +// passwordChangeRequest represents the request body for password change +type passwordChangeRequest struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` +} + +// changePassword is a PUT request to change user's password. +// Only available when embedded IDP is enabled. +// Users can only change their own password. +func (h *handler) changePassword(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + vars := mux.Vars(r) + targetUserID := vars["userId"] + if len(targetUserID) == 0 { + util.WriteErrorResponse("invalid user ID", http.StatusBadRequest, w) + return + } + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req passwordChangeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + err = h.accountManager.UpdateUserPassword(r.Context(), userAuth.AccountId, userAuth.UserId, targetUserID, req.OldPassword, req.NewPassword) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} diff --git a/management/server/http/handlers/users/users_handler_test.go b/management/server/http/handlers/users/users_handler_test.go index 37f0a6c1d..aa77dd843 100644 --- a/management/server/http/handlers/users/users_handler_test.go +++ b/management/server/http/handlers/users/users_handler_test.go @@ -856,3 +856,118 @@ func TestRejectUserEndpoint(t *testing.T) { }) } } + +func TestChangePasswordEndpoint(t *testing.T) { + tt := []struct { + name string + expectedStatus int + requestBody string + targetUserID string + currentUserID string + mockError error + expectMockNotCalled bool + }{ + { + name: "successful password change", + expectedStatus: http.StatusOK, + requestBody: `{"old_password": "OldPass123!", "new_password": "NewPass456!"}`, + targetUserID: existingUserID, + currentUserID: existingUserID, + mockError: nil, + }, + { + name: "missing old password", + expectedStatus: http.StatusUnprocessableEntity, + requestBody: `{"new_password": "NewPass456!"}`, + targetUserID: existingUserID, + currentUserID: existingUserID, + mockError: status.Errorf(status.InvalidArgument, "old password is required"), + }, + { + name: "missing new password", + expectedStatus: http.StatusUnprocessableEntity, + requestBody: `{"old_password": "OldPass123!"}`, + targetUserID: existingUserID, + currentUserID: existingUserID, + mockError: status.Errorf(status.InvalidArgument, "new password is required"), + }, + { + name: "wrong old password", + expectedStatus: http.StatusUnprocessableEntity, + requestBody: `{"old_password": "WrongPass!", "new_password": "NewPass456!"}`, + targetUserID: existingUserID, + currentUserID: existingUserID, + mockError: status.Errorf(status.InvalidArgument, "invalid password"), + }, + { + name: "embedded IDP not enabled", + expectedStatus: http.StatusPreconditionFailed, + requestBody: `{"old_password": "OldPass123!", "new_password": "NewPass456!"}`, + targetUserID: existingUserID, + currentUserID: existingUserID, + mockError: status.Errorf(status.PreconditionFailed, "password change is only available with embedded identity provider"), + }, + { + name: "invalid JSON request", + expectedStatus: http.StatusBadRequest, + requestBody: `{invalid json}`, + targetUserID: existingUserID, + currentUserID: existingUserID, + expectMockNotCalled: true, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + mockCalled := false + am := &mock_server.MockAccountManager{} + am.UpdateUserPasswordFunc = func(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error { + mockCalled = true + return tc.mockError + } + + handler := newHandler(am) + router := mux.NewRouter() + router.HandleFunc("/users/{userId}/password", handler.changePassword).Methods("PUT") + + reqPath := "/users/" + tc.targetUserID + "/password" + req, err := http.NewRequest("PUT", reqPath, bytes.NewBufferString(tc.requestBody)) + require.NoError(t, err) + + userAuth := auth.UserAuth{ + AccountId: existingAccountID, + UserId: tc.currentUserID, + } + ctx := nbcontext.SetUserAuthInContext(req.Context(), userAuth) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + + if tc.expectMockNotCalled { + assert.False(t, mockCalled, "mock should not have been called") + } + }) + } +} + +func TestChangePasswordEndpoint_WrongMethod(t *testing.T) { + am := &mock_server.MockAccountManager{} + handler := newHandler(am) + + req, err := http.NewRequest("POST", "/users/test-user/password", bytes.NewBufferString(`{}`)) + require.NoError(t, err) + + userAuth := auth.UserAuth{ + AccountId: existingAccountID, + UserId: existingUserID, + } + req = nbcontext.SetUserAuthInRequest(req, userAuth) + + rr := httptest.NewRecorder() + handler.changePassword(rr, req) + + assert.Equal(t, http.StatusMethodNotAllowed, rr.Code) +} diff --git a/management/server/idp/embedded.go b/management/server/idp/embedded.go index 0e46b506e..79859525b 100644 --- a/management/server/idp/embedded.go +++ b/management/server/idp/embedded.go @@ -400,7 +400,6 @@ func (m *EmbeddedIdPManager) CreateUserWithPassword(ctx context.Context, email, // InviteUserByID resends an invitation to a user. func (m *EmbeddedIdPManager) InviteUserByID(ctx context.Context, userID string) error { - // TODO: implement return fmt.Errorf("not implemented") } @@ -432,6 +431,33 @@ func (m *EmbeddedIdPManager) DeleteUser(ctx context.Context, userID string) erro return nil } +// UpdateUserPassword updates the password for a user in the embedded IdP. +// It verifies that the current user is changing their own password and +// validates the current password before updating to the new password. +func (m *EmbeddedIdPManager) UpdateUserPassword(ctx context.Context, currentUserID, targetUserID string, oldPassword, newPassword string) error { + // Verify the user is changing their own password + if currentUserID != targetUserID { + return fmt.Errorf("users can only change their own password") + } + + // Verify the new password is different from the old password + if oldPassword == newPassword { + return fmt.Errorf("new password must be different from current password") + } + + err := m.provider.UpdateUserPassword(ctx, targetUserID, oldPassword, newPassword) + if err != nil { + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountRequestError() + } + return err + } + + log.WithContext(ctx).Debugf("updated password for user %s in embedded IdP", targetUserID) + + return nil +} + // CreateConnector creates a new identity provider connector in Dex. // Returns the created connector config with the redirect URL populated. func (m *EmbeddedIdPManager) CreateConnector(ctx context.Context, cfg *dex.ConnectorConfig) (*dex.ConnectorConfig, error) { @@ -449,15 +475,8 @@ func (m *EmbeddedIdPManager) ListConnectors(ctx context.Context) ([]*dex.Connect } // UpdateConnector updates an existing identity provider connector. +// Field preservation for partial updates is handled by Provider.UpdateConnector. func (m *EmbeddedIdPManager) UpdateConnector(ctx context.Context, cfg *dex.ConnectorConfig) error { - // Preserve existing secret if not provided in update - if cfg.ClientSecret == "" { - existing, err := m.provider.GetConnector(ctx, cfg.ID) - if err != nil { - return fmt.Errorf("failed to get existing connector: %w", err) - } - cfg.ClientSecret = existing.ClientSecret - } return m.provider.UpdateConnector(ctx, cfg) } diff --git a/management/server/idp/embedded_test.go b/management/server/idp/embedded_test.go index 04e3f0699..d8d3009dd 100644 --- a/management/server/idp/embedded_test.go +++ b/management/server/idp/embedded_test.go @@ -248,6 +248,71 @@ func TestEmbeddedIdPManager_UserIDFormat_MatchesJWT(t *testing.T) { t.Logf(" Connector: %s", connectorID) } +func TestEmbeddedIdPManager_UpdateUserPassword(t *testing.T) { + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(tmpDir, "dex.db"), + }, + }, + } + + manager, err := NewEmbeddedIdPManager(ctx, config, nil) + require.NoError(t, err) + defer func() { _ = manager.Stop(ctx) }() + + // Create a user with a known password + email := "password-test@example.com" + name := "Password Test User" + initialPassword := "InitialPass123!" + + userData, err := manager.CreateUserWithPassword(ctx, email, initialPassword, name) + require.NoError(t, err) + require.NotNil(t, userData) + + userID := userData.ID + + t.Run("successful password change", func(t *testing.T) { + newPassword := "NewSecurePass456!" + err := manager.UpdateUserPassword(ctx, userID, userID, initialPassword, newPassword) + require.NoError(t, err) + + // Verify the new password works by changing it again + anotherPassword := "AnotherPass789!" + err = manager.UpdateUserPassword(ctx, userID, userID, newPassword, anotherPassword) + require.NoError(t, err) + }) + + t.Run("wrong old password", func(t *testing.T) { + err := manager.UpdateUserPassword(ctx, userID, userID, "wrongpassword", "NewPass123!") + require.Error(t, err) + assert.Contains(t, err.Error(), "current password is incorrect") + }) + + t.Run("cannot change other user password", func(t *testing.T) { + otherUserID := "other-user-id" + err := manager.UpdateUserPassword(ctx, userID, otherUserID, "oldpass", "newpass") + require.Error(t, err) + assert.Contains(t, err.Error(), "users can only change their own password") + }) + + t.Run("same password rejected", func(t *testing.T) { + samePassword := "SamePass123!" + err := manager.UpdateUserPassword(ctx, userID, userID, samePassword, samePassword) + require.Error(t, err) + assert.Contains(t, err.Error(), "new password must be different") + }) +} + func TestEmbeddedIdPManager_GetLocalKeysLocation(t *testing.T) { ctx := context.Background() diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index f5caa3bbc..75e971498 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -74,6 +74,7 @@ type MockAccountManager struct { SaveOrAddUsersFunc func(ctx context.Context, accountID, initiatorUserID string, update []*types.User, addIfNotExists bool) ([]*types.UserInfo, error) DeleteUserFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error DeleteRegularUsersFunc func(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error + UpdateUserPasswordFunc func(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error CreatePATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) DeletePATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenID string) error GetPATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenID string) (*types.PersonalAccessToken, error) @@ -135,9 +136,9 @@ type MockAccountManager struct { CreateIdentityProviderFunc func(ctx context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) UpdateIdentityProviderFunc func(ctx context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) DeleteIdentityProviderFunc func(ctx context.Context, accountID, idpID, userID string) error - CreatePeerJobFunc func(ctx context.Context, accountID, peerID, userID string, job *types.Job) error - GetAllPeerJobsFunc func(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error) - GetPeerJobByIDFunc func(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error) + CreatePeerJobFunc func(ctx context.Context, accountID, peerID, userID string, job *types.Job) error + GetAllPeerJobsFunc func(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error) + GetPeerJobByIDFunc func(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error) } func (am *MockAccountManager) CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error { @@ -635,6 +636,14 @@ func (am *MockAccountManager) DeleteRegularUsers(ctx context.Context, accountID, return status.Errorf(codes.Unimplemented, "method DeleteRegularUsers is not implemented") } +// UpdateUserPassword mocks UpdateUserPassword of the AccountManager interface +func (am *MockAccountManager) UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error { + if am.UpdateUserPasswordFunc != nil { + return am.UpdateUserPasswordFunc(ctx, accountID, currentUserID, targetUserID, oldPassword, newPassword) + } + return status.Errorf(codes.Unimplemented, "method UpdateUserPassword is not implemented") +} + func (am *MockAccountManager) InviteUser(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error { if am.InviteUserFunc != nil { return am.InviteUserFunc(ctx, accountID, initiatorUserID, targetUserID) diff --git a/management/server/user.go b/management/server/user.go index d12dd4f11..1f38b749f 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -249,6 +249,37 @@ func (am *DefaultAccountManager) ListUsers(ctx context.Context, accountID string return am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID) } +// UpdateUserPassword updates the password for a user in the embedded IdP. +// This is only available when the embedded IdP is enabled. +// Users can only change their own password. +func (am *DefaultAccountManager) UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error { + if !IsEmbeddedIdp(am.idpManager) { + return status.Errorf(status.PreconditionFailed, "password change is only available with embedded identity provider") + } + + if oldPassword == "" { + return status.Errorf(status.InvalidArgument, "old password is required") + } + + if newPassword == "" { + return status.Errorf(status.InvalidArgument, "new password is required") + } + + embeddedIdp, ok := am.idpManager.(*idp.EmbeddedIdPManager) + if !ok { + return status.Errorf(status.Internal, "failed to get embedded IdP manager") + } + + err := embeddedIdp.UpdateUserPassword(ctx, currentUserID, targetUserID, oldPassword, newPassword) + if err != nil { + return status.Errorf(status.InvalidArgument, "failed to update password: %v", err) + } + + am.StoreEvent(ctx, currentUserID, targetUserID, accountID, activity.UserPasswordChanged, nil) + + return nil +} + func (am *DefaultAccountManager) deleteServiceUser(ctx context.Context, accountID string, initiatorUserID string, targetUser *types.User) error { if err := am.Store.DeleteUser(ctx, accountID, targetUser.Id); err != nil { return err @@ -806,7 +837,20 @@ func (am *DefaultAccountManager) getUserInfo(ctx context.Context, user *types.Us } return user.ToUserInfo(userData) } - return user.ToUserInfo(nil) + + userInfo, err := user.ToUserInfo(nil) + if err != nil { + return nil, err + } + + // For embedded IDP users, extract the IdPID (connector ID) from the encoded user ID + if IsEmbeddedIdp(am.idpManager) && !user.IsServiceUser { + if _, connectorID, decodeErr := dex.DecodeDexUserID(user.Id); decodeErr == nil && connectorID != "" { + userInfo.IdPID = connectorID + } + } + + return userInfo, nil } // validateUserUpdate validates the update operation for a user. diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 29e81f15a..cc3fa10d8 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -44,6 +44,20 @@ tags: components: schemas: + PasswordChangeRequest: + type: object + properties: + old_password: + description: The current password + type: string + example: "currentPassword123" + new_password: + description: The new password to set + type: string + example: "newSecurePassword456" + required: + - old_password + - new_password WorkloadType: type: string description: | @@ -3205,6 +3219,43 @@ paths: "$ref": "#/components/responses/forbidden" '500': "$ref": "#/components/responses/internal_error" + /api/users/{userId}/password: + put: + summary: Change user password + description: Change the password for a user. Only available when embedded IdP is enabled. Users can only change their own password. + tags: [ Users ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: userId + required: true + schema: + type: string + description: The unique identifier of a user + requestBody: + description: Password change request + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordChangeRequest' + responses: + '200': + description: Password changed successfully + content: {} + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '412': + description: Precondition failed - embedded IdP is not enabled + content: { } + '500': + "$ref": "#/components/responses/internal_error" /api/users/current: get: summary: Retrieve current user diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 7a845b62f..17af8b06d 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1201,6 +1201,15 @@ type OSVersionCheck struct { Windows *MinKernelVersionCheck `json:"windows,omitempty"` } +// PasswordChangeRequest defines model for PasswordChangeRequest. +type PasswordChangeRequest struct { + // NewPassword The new password to set + NewPassword string `json:"new_password"` + + // OldPassword The current password + OldPassword string `json:"old_password"` +} + // Peer defines model for Peer. type Peer struct { // ApprovalRequired (Cloud only) Indicates whether peer needs approval @@ -2354,6 +2363,9 @@ type PostApiUsersJSONRequestBody = UserCreateRequest // PutApiUsersUserIdJSONRequestBody defines body for PutApiUsersUserId for application/json ContentType. type PutApiUsersUserIdJSONRequestBody = UserRequest +// PutApiUsersUserIdPasswordJSONRequestBody defines body for PutApiUsersUserIdPassword for application/json ContentType. +type PutApiUsersUserIdPasswordJSONRequestBody = PasswordChangeRequest + // PostApiUsersUserIdTokensJSONRequestBody defines body for PostApiUsersUserIdTokens for application/json ContentType. type PostApiUsersUserIdTokensJSONRequestBody = PersonalAccessTokenRequest