mirror of
https://github.com/netbirdio/netbird.git
synced 2026-03-31 06:34:14 -04:00
Add API for managing external identity provider connectors through
Zitadel. Supports OIDC, LDAP, and SAML connector types.
New endpoints:
- GET/DELETE /api/connectors - list and delete connectors
- POST /api/connectors/oidc - add OIDC connector
- POST /api/connectors/ldap - add LDAP connector
- POST /api/connectors/saml - add SAML connector
- POST /api/connectors/{id}/activate - activate connector
- POST /api/connectors/{id}/deactivate - deactivate connector
This commit is contained in:
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/geolocation"
|
||||
nbgroups "github.com/netbirdio/netbird/management/server/groups"
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/accounts"
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/connectors"
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/dns"
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/events"
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/groups"
|
||||
@@ -134,6 +135,7 @@ func NewAPIHandler(
|
||||
dns.AddEndpoints(accountManager, router)
|
||||
events.AddEndpoints(accountManager, router)
|
||||
networks.AddEndpoints(networksManager, resourceManager, routerManager, groupsManager, accountManager, router)
|
||||
connectors.AddEndpoints(accountManager, router)
|
||||
|
||||
return rootRouter, nil
|
||||
}
|
||||
|
||||
590
management/server/http/handlers/connectors/connectors_handler.go
Normal file
590
management/server/http/handlers/connectors/connectors_handler.go
Normal file
@@ -0,0 +1,590 @@
|
||||
package connectors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// API request/response types
|
||||
|
||||
// ConnectorResponse represents an IdP connector in API responses
|
||||
type ConnectorResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
State string `json:"state"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
Servers []string `json:"servers,omitempty"`
|
||||
}
|
||||
|
||||
// OIDCConnectorRequest represents a request to create an OIDC connector
|
||||
type OIDCConnectorRequest struct {
|
||||
Name string `json:"name"`
|
||||
Issuer string `json:"issuer"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
IsAutoCreation bool `json:"is_auto_creation,omitempty"`
|
||||
IsAutoUpdate bool `json:"is_auto_update,omitempty"`
|
||||
IsCreationAllowed bool `json:"is_creation_allowed,omitempty"`
|
||||
IsLinkingAllowed bool `json:"is_linking_allowed,omitempty"`
|
||||
}
|
||||
|
||||
// LDAPConnectorRequest represents a request to create an LDAP connector
|
||||
type LDAPConnectorRequest struct {
|
||||
Name string `json:"name"`
|
||||
Servers []string `json:"servers"`
|
||||
StartTLS bool `json:"start_tls,omitempty"`
|
||||
BaseDN string `json:"base_dn"`
|
||||
BindDN string `json:"bind_dn"`
|
||||
BindPassword string `json:"bind_password"`
|
||||
UserBase string `json:"user_base,omitempty"`
|
||||
UserObjectClass []string `json:"user_object_class,omitempty"`
|
||||
UserFilters []string `json:"user_filters,omitempty"`
|
||||
Timeout string `json:"timeout,omitempty"`
|
||||
Attributes *LDAPAttributesRequest `json:"attributes,omitempty"`
|
||||
IsAutoCreation bool `json:"is_auto_creation,omitempty"`
|
||||
IsAutoUpdate bool `json:"is_auto_update,omitempty"`
|
||||
IsCreationAllowed bool `json:"is_creation_allowed,omitempty"`
|
||||
IsLinkingAllowed bool `json:"is_linking_allowed,omitempty"`
|
||||
}
|
||||
|
||||
// LDAPAttributesRequest maps LDAP attributes to user fields
|
||||
type LDAPAttributesRequest struct {
|
||||
IDAttribute string `json:"id_attribute,omitempty"`
|
||||
FirstNameAttribute string `json:"first_name_attribute,omitempty"`
|
||||
LastNameAttribute string `json:"last_name_attribute,omitempty"`
|
||||
DisplayNameAttribute string `json:"display_name_attribute,omitempty"`
|
||||
EmailAttribute string `json:"email_attribute,omitempty"`
|
||||
}
|
||||
|
||||
// SAMLConnectorRequest represents a request to create a SAML connector
|
||||
type SAMLConnectorRequest struct {
|
||||
Name string `json:"name"`
|
||||
MetadataXML string `json:"metadata_xml,omitempty"`
|
||||
MetadataURL string `json:"metadata_url,omitempty"`
|
||||
Binding string `json:"binding,omitempty"`
|
||||
WithSignedRequest bool `json:"with_signed_request,omitempty"`
|
||||
NameIDFormat string `json:"name_id_format,omitempty"`
|
||||
IsAutoCreation bool `json:"is_auto_creation,omitempty"`
|
||||
IsAutoUpdate bool `json:"is_auto_update,omitempty"`
|
||||
IsCreationAllowed bool `json:"is_creation_allowed,omitempty"`
|
||||
IsLinkingAllowed bool `json:"is_linking_allowed,omitempty"`
|
||||
}
|
||||
|
||||
// handler handles HTTP requests for IdP connectors
|
||||
type handler struct {
|
||||
accountManager account.Manager
|
||||
}
|
||||
|
||||
// AddEndpoints registers the connector endpoints to the router
|
||||
func AddEndpoints(accountManager account.Manager, router *mux.Router) {
|
||||
h := &handler{accountManager: accountManager}
|
||||
|
||||
router.HandleFunc("/connectors", h.listConnectors).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/connectors/{connectorId}", h.getConnector).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/connectors/{connectorId}", h.deleteConnector).Methods("DELETE", "OPTIONS")
|
||||
router.HandleFunc("/connectors/oidc", h.addOIDCConnector).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/connectors/ldap", h.addLDAPConnector).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/connectors/saml", h.addSAMLConnector).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/connectors/{connectorId}/activate", h.activateConnector).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/connectors/{connectorId}/deactivate", h.deleteConnector).Methods("POST", "OPTIONS")
|
||||
}
|
||||
|
||||
// getConnectorManager retrieves the connector manager from the IdP manager
|
||||
func (h *handler) getConnectorManager() (idp.ConnectorManager, error) {
|
||||
idpManager := h.accountManager.GetIdpManager()
|
||||
if idpManager == nil {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "IdP manager is not configured")
|
||||
}
|
||||
|
||||
connectorManager, ok := idpManager.(idp.ConnectorManager)
|
||||
if !ok {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "IdP manager does not support connector management")
|
||||
}
|
||||
|
||||
return connectorManager, nil
|
||||
}
|
||||
|
||||
// listConnectors returns all configured IdP connectors
|
||||
func (h *handler) listConnectors(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
|
||||
return
|
||||
}
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
// Only admins can manage connectors
|
||||
user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.HasAdminPower() {
|
||||
util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w)
|
||||
return
|
||||
}
|
||||
|
||||
connectorManager, err := h.getConnectorManager()
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
connectors, err := connectorManager.ListConnectors(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), status.Errorf(status.Internal, "failed to list connectors: %v", err), w)
|
||||
return
|
||||
}
|
||||
|
||||
response := make([]*ConnectorResponse, 0, len(connectors))
|
||||
for _, c := range connectors {
|
||||
response = append(response, toConnectorResponse(c))
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, response)
|
||||
}
|
||||
|
||||
// getConnector returns a specific connector by ID
|
||||
func (h *handler) getConnector(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
|
||||
return
|
||||
}
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.HasAdminPower() {
|
||||
util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
connectorID := vars["connectorId"]
|
||||
if connectorID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "connector ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
connectorManager, err := h.getConnectorManager()
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
connector, err := connectorManager.GetConnector(r.Context(), connectorID)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), status.Errorf(status.NotFound, "connector not found: %v", err), w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, toConnectorResponse(connector))
|
||||
}
|
||||
|
||||
// deleteConnector removes a connector
|
||||
func (h *handler) deleteConnector(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
|
||||
return
|
||||
}
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.HasAdminPower() {
|
||||
util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
connectorID := vars["connectorId"]
|
||||
if connectorID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "connector ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
connectorManager, err := h.getConnectorManager()
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := connectorManager.DeleteConnector(r.Context(), connectorID); err != nil {
|
||||
util.WriteError(r.Context(), status.Errorf(status.Internal, "failed to delete connector: %v", err), w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
||||
}
|
||||
|
||||
// addOIDCConnector creates a new OIDC connector
|
||||
func (h *handler) addOIDCConnector(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
|
||||
return
|
||||
}
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.HasAdminPower() {
|
||||
util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w)
|
||||
return
|
||||
}
|
||||
|
||||
var req OIDCConnectorRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("invalid request body", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "name is required"), w)
|
||||
return
|
||||
}
|
||||
if req.Issuer == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "issuer is required"), w)
|
||||
return
|
||||
}
|
||||
if req.ClientID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "client_id is required"), w)
|
||||
return
|
||||
}
|
||||
if req.ClientSecret == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "client_secret is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
connectorManager, err := h.getConnectorManager()
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
config := idp.OIDCConnectorConfig{
|
||||
Name: req.Name,
|
||||
Issuer: req.Issuer,
|
||||
ClientID: req.ClientID,
|
||||
ClientSecret: req.ClientSecret,
|
||||
Scopes: req.Scopes,
|
||||
IsAutoCreation: req.IsAutoCreation,
|
||||
IsAutoUpdate: req.IsAutoUpdate,
|
||||
IsCreationAllowed: req.IsCreationAllowed,
|
||||
IsLinkingAllowed: req.IsLinkingAllowed,
|
||||
}
|
||||
|
||||
connector, err := connectorManager.AddOIDCConnector(r.Context(), config)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), status.Errorf(status.Internal, "failed to add OIDC connector: %v", err), w)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
util.WriteJSONObject(r.Context(), w, toConnectorResponse(connector))
|
||||
}
|
||||
|
||||
// addLDAPConnector creates a new LDAP connector
|
||||
func (h *handler) addLDAPConnector(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
|
||||
return
|
||||
}
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.HasAdminPower() {
|
||||
util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w)
|
||||
return
|
||||
}
|
||||
|
||||
var req LDAPConnectorRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("invalid request body", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "name is required"), w)
|
||||
return
|
||||
}
|
||||
if len(req.Servers) == 0 {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "at least one server is required"), w)
|
||||
return
|
||||
}
|
||||
if req.BaseDN == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "base_dn is required"), w)
|
||||
return
|
||||
}
|
||||
if req.BindDN == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "bind_dn is required"), w)
|
||||
return
|
||||
}
|
||||
if req.BindPassword == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "bind_password is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
connectorManager, err := h.getConnectorManager()
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
config := idp.LDAPConnectorConfig{
|
||||
Name: req.Name,
|
||||
Servers: req.Servers,
|
||||
StartTLS: req.StartTLS,
|
||||
BaseDN: req.BaseDN,
|
||||
BindDN: req.BindDN,
|
||||
BindPassword: req.BindPassword,
|
||||
UserBase: req.UserBase,
|
||||
UserObjectClass: req.UserObjectClass,
|
||||
UserFilters: req.UserFilters,
|
||||
Timeout: req.Timeout,
|
||||
IsAutoCreation: req.IsAutoCreation,
|
||||
IsAutoUpdate: req.IsAutoUpdate,
|
||||
IsCreationAllowed: req.IsCreationAllowed,
|
||||
IsLinkingAllowed: req.IsLinkingAllowed,
|
||||
}
|
||||
|
||||
if req.Attributes != nil {
|
||||
config.Attributes = idp.LDAPAttributes{
|
||||
IDAttribute: req.Attributes.IDAttribute,
|
||||
FirstNameAttribute: req.Attributes.FirstNameAttribute,
|
||||
LastNameAttribute: req.Attributes.LastNameAttribute,
|
||||
DisplayNameAttribute: req.Attributes.DisplayNameAttribute,
|
||||
EmailAttribute: req.Attributes.EmailAttribute,
|
||||
}
|
||||
}
|
||||
|
||||
connector, err := connectorManager.AddLDAPConnector(r.Context(), config)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), status.Errorf(status.Internal, "failed to add LDAP connector: %v", err), w)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
util.WriteJSONObject(r.Context(), w, toConnectorResponse(connector))
|
||||
}
|
||||
|
||||
// addSAMLConnector creates a new SAML connector
|
||||
func (h *handler) addSAMLConnector(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
|
||||
return
|
||||
}
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.HasAdminPower() {
|
||||
util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w)
|
||||
return
|
||||
}
|
||||
|
||||
var req SAMLConnectorRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("invalid request body", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "name is required"), w)
|
||||
return
|
||||
}
|
||||
if req.MetadataXML == "" && req.MetadataURL == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "either metadata_xml or metadata_url is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
connectorManager, err := h.getConnectorManager()
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
config := idp.SAMLConnectorConfig{
|
||||
Name: req.Name,
|
||||
MetadataXML: req.MetadataXML,
|
||||
MetadataURL: req.MetadataURL,
|
||||
Binding: req.Binding,
|
||||
WithSignedRequest: req.WithSignedRequest,
|
||||
NameIDFormat: req.NameIDFormat,
|
||||
IsAutoCreation: req.IsAutoCreation,
|
||||
IsAutoUpdate: req.IsAutoUpdate,
|
||||
IsCreationAllowed: req.IsCreationAllowed,
|
||||
IsLinkingAllowed: req.IsLinkingAllowed,
|
||||
}
|
||||
|
||||
connector, err := connectorManager.AddSAMLConnector(r.Context(), config)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), status.Errorf(status.Internal, "failed to add SAML connector: %v", err), w)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
util.WriteJSONObject(r.Context(), w, toConnectorResponse(connector))
|
||||
}
|
||||
|
||||
// activateConnector adds the connector to the login policy
|
||||
func (h *handler) activateConnector(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
|
||||
return
|
||||
}
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.HasAdminPower() {
|
||||
util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
connectorID := vars["connectorId"]
|
||||
if connectorID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "connector ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
connectorManager, err := h.getConnectorManager()
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := connectorManager.ActivateConnector(r.Context(), connectorID); err != nil {
|
||||
util.WriteError(r.Context(), status.Errorf(status.Internal, "failed to activate connector: %v", err), w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
||||
}
|
||||
|
||||
// deactivateConnector removes the connector from the login policy
|
||||
func (h *handler) deactivateConnector(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
|
||||
return
|
||||
}
|
||||
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.HasAdminPower() {
|
||||
util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
connectorID := vars["connectorId"]
|
||||
if connectorID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "connector ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
connectorManager, err := h.getConnectorManager()
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := connectorManager.DeactivateConnector(r.Context(), connectorID); err != nil {
|
||||
util.WriteError(r.Context(), status.Errorf(status.Internal, "failed to deactivate connector: %v", err), w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
||||
}
|
||||
|
||||
// toConnectorResponse converts an idp.Connector to a ConnectorResponse
|
||||
func toConnectorResponse(c *idp.Connector) *ConnectorResponse {
|
||||
return &ConnectorResponse{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Type: string(c.Type),
|
||||
State: c.State,
|
||||
Issuer: c.Issuer,
|
||||
Servers: c.Servers,
|
||||
}
|
||||
}
|
||||
457
management/server/idp/connector.go
Normal file
457
management/server/idp/connector.go
Normal file
@@ -0,0 +1,457 @@
|
||||
package idp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ConnectorType represents the type of external identity provider connector
|
||||
type ConnectorType string
|
||||
|
||||
const (
|
||||
ConnectorTypeOIDC ConnectorType = "oidc"
|
||||
ConnectorTypeLDAP ConnectorType = "ldap"
|
||||
ConnectorTypeSAML ConnectorType = "saml"
|
||||
)
|
||||
|
||||
// Connector represents an external identity provider configured in Zitadel
|
||||
type Connector struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type ConnectorType `json:"type"`
|
||||
State string `json:"state"`
|
||||
Issuer string `json:"issuer,omitempty"` // for OIDC
|
||||
Servers []string `json:"servers,omitempty"` // for LDAP
|
||||
}
|
||||
|
||||
// OIDCConnectorConfig contains configuration for adding an OIDC connector
|
||||
type OIDCConnectorConfig struct {
|
||||
Name string `json:"name"`
|
||||
Issuer string `json:"issuer"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
IsIDTokenMapping bool `json:"isIdTokenMapping,omitempty"`
|
||||
IsAutoCreation bool `json:"isAutoCreation,omitempty"`
|
||||
IsAutoUpdate bool `json:"isAutoUpdate,omitempty"`
|
||||
IsCreationAllowed bool `json:"isCreationAllowed,omitempty"`
|
||||
IsLinkingAllowed bool `json:"isLinkingAllowed,omitempty"`
|
||||
IsAutoAccountLinking bool `json:"isAutoAccountLinking,omitempty"`
|
||||
AccountLinkingEnabled bool `json:"accountLinkingEnabled,omitempty"`
|
||||
}
|
||||
|
||||
// LDAPConnectorConfig contains configuration for adding an LDAP connector
|
||||
type LDAPConnectorConfig struct {
|
||||
Name string `json:"name"`
|
||||
Servers []string `json:"servers"` // e.g., ["ldap://localhost:389"]
|
||||
StartTLS bool `json:"startTls,omitempty"`
|
||||
BaseDN string `json:"baseDn"`
|
||||
BindDN string `json:"bindDn"`
|
||||
BindPassword string `json:"bindPassword"`
|
||||
UserBase string `json:"userBase,omitempty"` // typically "dn"
|
||||
UserObjectClass []string `json:"userObjectClass,omitempty"` // e.g., ["user", "person"]
|
||||
UserFilters []string `json:"userFilters,omitempty"` // e.g., ["uid", "email"]
|
||||
Timeout string `json:"timeout,omitempty"` // e.g., "10s"
|
||||
Attributes LDAPAttributes `json:"attributes,omitempty"`
|
||||
IsAutoCreation bool `json:"isAutoCreation,omitempty"`
|
||||
IsAutoUpdate bool `json:"isAutoUpdate,omitempty"`
|
||||
IsCreationAllowed bool `json:"isCreationAllowed,omitempty"`
|
||||
IsLinkingAllowed bool `json:"isLinkingAllowed,omitempty"`
|
||||
}
|
||||
|
||||
// LDAPAttributes maps LDAP attributes to Zitadel user fields
|
||||
type LDAPAttributes struct {
|
||||
IDAttribute string `json:"idAttribute,omitempty"`
|
||||
FirstNameAttribute string `json:"firstNameAttribute,omitempty"`
|
||||
LastNameAttribute string `json:"lastNameAttribute,omitempty"`
|
||||
DisplayNameAttribute string `json:"displayNameAttribute,omitempty"`
|
||||
NickNameAttribute string `json:"nickNameAttribute,omitempty"`
|
||||
EmailAttribute string `json:"emailAttribute,omitempty"`
|
||||
EmailVerified string `json:"emailVerified,omitempty"`
|
||||
PhoneAttribute string `json:"phoneAttribute,omitempty"`
|
||||
PhoneVerified string `json:"phoneVerified,omitempty"`
|
||||
AvatarURLAttribute string `json:"avatarUrlAttribute,omitempty"`
|
||||
ProfileAttribute string `json:"profileAttribute,omitempty"`
|
||||
}
|
||||
|
||||
// SAMLConnectorConfig contains configuration for adding a SAML connector
|
||||
type SAMLConnectorConfig struct {
|
||||
Name string `json:"name"`
|
||||
MetadataXML string `json:"metadataXml,omitempty"`
|
||||
MetadataURL string `json:"metadataUrl,omitempty"`
|
||||
Binding string `json:"binding,omitempty"` // "SAML_BINDING_POST" or "SAML_BINDING_REDIRECT"
|
||||
WithSignedRequest bool `json:"withSignedRequest,omitempty"`
|
||||
NameIDFormat string `json:"nameIdFormat,omitempty"`
|
||||
IsAutoCreation bool `json:"isAutoCreation,omitempty"`
|
||||
IsAutoUpdate bool `json:"isAutoUpdate,omitempty"`
|
||||
IsCreationAllowed bool `json:"isCreationAllowed,omitempty"`
|
||||
IsLinkingAllowed bool `json:"isLinkingAllowed,omitempty"`
|
||||
}
|
||||
|
||||
// ConnectorManager defines the interface for managing external IdP connectors
|
||||
type ConnectorManager interface {
|
||||
// AddOIDCConnector adds a Generic OIDC identity provider connector
|
||||
AddOIDCConnector(ctx context.Context, config OIDCConnectorConfig) (*Connector, error)
|
||||
// AddLDAPConnector adds an LDAP identity provider connector
|
||||
AddLDAPConnector(ctx context.Context, config LDAPConnectorConfig) (*Connector, error)
|
||||
// AddSAMLConnector adds a SAML identity provider connector
|
||||
AddSAMLConnector(ctx context.Context, config SAMLConnectorConfig) (*Connector, error)
|
||||
// ListConnectors returns all configured identity provider connectors
|
||||
ListConnectors(ctx context.Context) ([]*Connector, error)
|
||||
// GetConnector returns a specific connector by ID
|
||||
GetConnector(ctx context.Context, connectorID string) (*Connector, error)
|
||||
// DeleteConnector removes an identity provider connector
|
||||
DeleteConnector(ctx context.Context, connectorID string) error
|
||||
// ActivateConnector adds the connector to the login policy
|
||||
ActivateConnector(ctx context.Context, connectorID string) error
|
||||
// DeactivateConnector removes the connector from the login policy
|
||||
DeactivateConnector(ctx context.Context, connectorID string) error
|
||||
}
|
||||
|
||||
// zitadelProviderResponse represents the response from creating a provider
|
||||
type zitadelProviderResponse struct {
|
||||
ID string `json:"id"`
|
||||
Details struct {
|
||||
Sequence string `json:"sequence"`
|
||||
CreationDate string `json:"creationDate"`
|
||||
ChangeDate string `json:"changeDate"`
|
||||
ResourceOwner string `json:"resourceOwner"`
|
||||
} `json:"details"`
|
||||
}
|
||||
|
||||
// zitadelProviderTemplate represents a provider in the list response
|
||||
type zitadelProviderTemplate struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"` // IDP_STATE_ACTIVE, IDP_STATE_INACTIVE
|
||||
Type string `json:"type"` // IDP_TYPE_OIDC, IDP_TYPE_LDAP, IDP_TYPE_SAML, etc.
|
||||
Owner string `json:"owner"` // IDP_OWNER_TYPE_ORG, IDP_OWNER_TYPE_SYSTEM
|
||||
// Type-specific fields
|
||||
OIDC *struct {
|
||||
Issuer string `json:"issuer"`
|
||||
ClientID string `json:"clientId"`
|
||||
} `json:"oidc,omitempty"`
|
||||
LDAP *struct {
|
||||
Servers []string `json:"servers"`
|
||||
BaseDN string `json:"baseDn"`
|
||||
} `json:"ldap,omitempty"`
|
||||
SAML *struct {
|
||||
MetadataURL string `json:"metadataUrl"`
|
||||
} `json:"saml,omitempty"`
|
||||
}
|
||||
|
||||
// AddOIDCConnector adds a Generic OIDC identity provider connector to Zitadel
|
||||
func (zm *ZitadelManager) AddOIDCConnector(ctx context.Context, config OIDCConnectorConfig) (*Connector, error) {
|
||||
// Set defaults for creation/linking if not specified
|
||||
if !config.IsCreationAllowed && !config.IsLinkingAllowed {
|
||||
config.IsCreationAllowed = true
|
||||
config.IsLinkingAllowed = true
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"name": config.Name,
|
||||
"issuer": config.Issuer,
|
||||
"clientId": config.ClientID,
|
||||
"clientSecret": config.ClientSecret,
|
||||
"isIdTokenMapping": config.IsIDTokenMapping,
|
||||
"isAutoCreation": config.IsAutoCreation,
|
||||
"isAutoUpdate": config.IsAutoUpdate,
|
||||
"isCreationAllowed": config.IsCreationAllowed,
|
||||
"isLinkingAllowed": config.IsLinkingAllowed,
|
||||
}
|
||||
|
||||
if len(config.Scopes) > 0 {
|
||||
payload["scopes"] = config.Scopes
|
||||
}
|
||||
|
||||
body, err := zm.helper.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal OIDC connector config: %w", err)
|
||||
}
|
||||
|
||||
respBody, err := zm.post(ctx, "idps/generic_oidc", string(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add OIDC connector: %w", err)
|
||||
}
|
||||
|
||||
var resp zitadelProviderResponse
|
||||
if err := zm.helper.Unmarshal(respBody, &resp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal OIDC connector response: %w", err)
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
ID: resp.ID,
|
||||
Name: config.Name,
|
||||
Type: ConnectorTypeOIDC,
|
||||
State: "active",
|
||||
Issuer: config.Issuer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AddLDAPConnector adds an LDAP identity provider connector to Zitadel
|
||||
func (zm *ZitadelManager) AddLDAPConnector(ctx context.Context, config LDAPConnectorConfig) (*Connector, error) {
|
||||
// Set defaults
|
||||
if !config.IsCreationAllowed && !config.IsLinkingAllowed {
|
||||
config.IsCreationAllowed = true
|
||||
config.IsLinkingAllowed = true
|
||||
}
|
||||
if config.UserBase == "" {
|
||||
config.UserBase = "dn"
|
||||
}
|
||||
if config.Timeout == "" {
|
||||
config.Timeout = "10s"
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"name": config.Name,
|
||||
"servers": config.Servers,
|
||||
"startTls": config.StartTLS,
|
||||
"baseDn": config.BaseDN,
|
||||
"bindDn": config.BindDN,
|
||||
"bindPassword": config.BindPassword,
|
||||
"userBase": config.UserBase,
|
||||
"timeout": config.Timeout,
|
||||
"isAutoCreation": config.IsAutoCreation,
|
||||
"isAutoUpdate": config.IsAutoUpdate,
|
||||
"isCreationAllowed": config.IsCreationAllowed,
|
||||
"isLinkingAllowed": config.IsLinkingAllowed,
|
||||
}
|
||||
|
||||
if len(config.UserObjectClass) > 0 {
|
||||
payload["userObjectClasses"] = config.UserObjectClass
|
||||
}
|
||||
if len(config.UserFilters) > 0 {
|
||||
payload["userFilters"] = config.UserFilters
|
||||
}
|
||||
|
||||
// Add attribute mappings if provided
|
||||
attrs := make(map[string]string)
|
||||
if config.Attributes.IDAttribute != "" {
|
||||
attrs["idAttribute"] = config.Attributes.IDAttribute
|
||||
}
|
||||
if config.Attributes.FirstNameAttribute != "" {
|
||||
attrs["firstNameAttribute"] = config.Attributes.FirstNameAttribute
|
||||
}
|
||||
if config.Attributes.LastNameAttribute != "" {
|
||||
attrs["lastNameAttribute"] = config.Attributes.LastNameAttribute
|
||||
}
|
||||
if config.Attributes.DisplayNameAttribute != "" {
|
||||
attrs["displayNameAttribute"] = config.Attributes.DisplayNameAttribute
|
||||
}
|
||||
if config.Attributes.EmailAttribute != "" {
|
||||
attrs["emailAttribute"] = config.Attributes.EmailAttribute
|
||||
}
|
||||
if len(attrs) > 0 {
|
||||
payload["attributes"] = attrs
|
||||
}
|
||||
|
||||
body, err := zm.helper.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal LDAP connector config: %w", err)
|
||||
}
|
||||
|
||||
respBody, err := zm.post(ctx, "idps/ldap", string(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add LDAP connector: %w", err)
|
||||
}
|
||||
|
||||
var resp zitadelProviderResponse
|
||||
if err := zm.helper.Unmarshal(respBody, &resp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal LDAP connector response: %w", err)
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
ID: resp.ID,
|
||||
Name: config.Name,
|
||||
Type: ConnectorTypeLDAP,
|
||||
State: "active",
|
||||
Servers: config.Servers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AddSAMLConnector adds a SAML identity provider connector to Zitadel
|
||||
func (zm *ZitadelManager) AddSAMLConnector(ctx context.Context, config SAMLConnectorConfig) (*Connector, error) {
|
||||
// Set defaults
|
||||
if !config.IsCreationAllowed && !config.IsLinkingAllowed {
|
||||
config.IsCreationAllowed = true
|
||||
config.IsLinkingAllowed = true
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"name": config.Name,
|
||||
"isAutoCreation": config.IsAutoCreation,
|
||||
"isAutoUpdate": config.IsAutoUpdate,
|
||||
"isCreationAllowed": config.IsCreationAllowed,
|
||||
"isLinkingAllowed": config.IsLinkingAllowed,
|
||||
}
|
||||
|
||||
if config.MetadataXML != "" {
|
||||
payload["metadataXml"] = config.MetadataXML
|
||||
} else if config.MetadataURL != "" {
|
||||
payload["metadataUrl"] = config.MetadataURL
|
||||
} else {
|
||||
return nil, fmt.Errorf("either metadataXml or metadataUrl must be provided")
|
||||
}
|
||||
|
||||
if config.Binding != "" {
|
||||
payload["binding"] = config.Binding
|
||||
}
|
||||
if config.WithSignedRequest {
|
||||
payload["withSignedRequest"] = config.WithSignedRequest
|
||||
}
|
||||
if config.NameIDFormat != "" {
|
||||
payload["nameIdFormat"] = config.NameIDFormat
|
||||
}
|
||||
|
||||
body, err := zm.helper.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal SAML connector config: %w", err)
|
||||
}
|
||||
|
||||
respBody, err := zm.post(ctx, "idps/saml", string(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add SAML connector: %w", err)
|
||||
}
|
||||
|
||||
var resp zitadelProviderResponse
|
||||
if err := zm.helper.Unmarshal(respBody, &resp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal SAML connector response: %w", err)
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
ID: resp.ID,
|
||||
Name: config.Name,
|
||||
Type: ConnectorTypeSAML,
|
||||
State: "active",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListConnectors returns all configured identity provider connectors
|
||||
func (zm *ZitadelManager) ListConnectors(ctx context.Context) ([]*Connector, error) {
|
||||
// Use the search endpoint to list all providers
|
||||
respBody, err := zm.post(ctx, "idps/_search", "{}")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list connectors: %w", err)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Result []zitadelProviderTemplate `json:"result"`
|
||||
}
|
||||
if err := zm.helper.Unmarshal(respBody, &resp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal connectors response: %w", err)
|
||||
}
|
||||
|
||||
connectors := make([]*Connector, 0, len(resp.Result))
|
||||
for _, p := range resp.Result {
|
||||
connector := &Connector{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
State: normalizeState(p.State),
|
||||
Type: normalizeType(p.Type),
|
||||
}
|
||||
|
||||
// Add type-specific fields
|
||||
if p.OIDC != nil {
|
||||
connector.Issuer = p.OIDC.Issuer
|
||||
}
|
||||
if p.LDAP != nil {
|
||||
connector.Servers = p.LDAP.Servers
|
||||
}
|
||||
|
||||
connectors = append(connectors, connector)
|
||||
}
|
||||
|
||||
return connectors, nil
|
||||
}
|
||||
|
||||
// GetConnector returns a specific connector by ID
|
||||
func (zm *ZitadelManager) GetConnector(ctx context.Context, connectorID string) (*Connector, error) {
|
||||
respBody, err := zm.get(ctx, fmt.Sprintf("idps/%s", connectorID), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get connector: %w", err)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
IDP zitadelProviderTemplate `json:"idp"`
|
||||
}
|
||||
if err := zm.helper.Unmarshal(respBody, &resp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal connector response: %w", err)
|
||||
}
|
||||
|
||||
connector := &Connector{
|
||||
ID: resp.IDP.ID,
|
||||
Name: resp.IDP.Name,
|
||||
State: normalizeState(resp.IDP.State),
|
||||
Type: normalizeType(resp.IDP.Type),
|
||||
}
|
||||
|
||||
if resp.IDP.OIDC != nil {
|
||||
connector.Issuer = resp.IDP.OIDC.Issuer
|
||||
}
|
||||
if resp.IDP.LDAP != nil {
|
||||
connector.Servers = resp.IDP.LDAP.Servers
|
||||
}
|
||||
|
||||
return connector, nil
|
||||
}
|
||||
|
||||
// DeleteConnector removes an identity provider connector
|
||||
func (zm *ZitadelManager) DeleteConnector(ctx context.Context, connectorID string) error {
|
||||
if err := zm.delete(ctx, fmt.Sprintf("idps/%s", connectorID)); err != nil {
|
||||
return fmt.Errorf("delete connector: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActivateConnector adds the connector to the organization's login policy
|
||||
func (zm *ZitadelManager) ActivateConnector(ctx context.Context, connectorID string) error {
|
||||
payload := map[string]string{
|
||||
"idpId": connectorID,
|
||||
}
|
||||
|
||||
body, err := zm.helper.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal activate request: %w", err)
|
||||
}
|
||||
|
||||
_, err = zm.post(ctx, "policies/login/idps", string(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("activate connector: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeactivateConnector removes the connector from the organization's login policy
|
||||
func (zm *ZitadelManager) DeactivateConnector(ctx context.Context, connectorID string) error {
|
||||
if err := zm.delete(ctx, fmt.Sprintf("policies/login/idps/%s", connectorID)); err != nil {
|
||||
return fmt.Errorf("deactivate connector: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeState converts Zitadel state to a simple string
|
||||
func normalizeState(state string) string {
|
||||
switch state {
|
||||
case "IDP_STATE_ACTIVE":
|
||||
return "active"
|
||||
case "IDP_STATE_INACTIVE":
|
||||
return "inactive"
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeType converts Zitadel type to ConnectorType
|
||||
func normalizeType(idpType string) ConnectorType {
|
||||
switch idpType {
|
||||
case "IDP_TYPE_OIDC", "IDP_TYPE_OIDC_GENERIC":
|
||||
return ConnectorTypeOIDC
|
||||
case "IDP_TYPE_LDAP":
|
||||
return ConnectorTypeLDAP
|
||||
case "IDP_TYPE_SAML":
|
||||
return ConnectorTypeSAML
|
||||
default:
|
||||
return ConnectorType(idpType)
|
||||
}
|
||||
}
|
||||
313
management/server/idp/connector_test.go
Normal file
313
management/server/idp/connector_test.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package idp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestZitadelManager_AddOIDCConnector(t *testing.T) {
|
||||
// Create a mock response for the OIDC connector creation
|
||||
mockResponse := `{"id": "oidc-123", "details": {"sequence": "1", "creationDate": "2024-01-01T00:00:00Z", "changeDate": "2024-01-01T00:00:00Z", "resourceOwner": "org-1"}}`
|
||||
|
||||
mockClient := &mockHTTPClient{
|
||||
code: http.StatusCreated,
|
||||
resBody: mockResponse,
|
||||
}
|
||||
|
||||
manager := &ZitadelManager{
|
||||
managementEndpoint: "https://zitadel.example.com/management/v1",
|
||||
httpClient: mockClient,
|
||||
credentials: &mockCredentials{token: "test-token"},
|
||||
helper: JsonParser{},
|
||||
}
|
||||
|
||||
config := OIDCConnectorConfig{
|
||||
Name: "Okta",
|
||||
Issuer: "https://okta.example.com",
|
||||
ClientID: "client-123",
|
||||
ClientSecret: "secret-456",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
}
|
||||
|
||||
connector, err := manager.AddOIDCConnector(context.Background(), config)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "oidc-123", connector.ID)
|
||||
assert.Equal(t, "Okta", connector.Name)
|
||||
assert.Equal(t, ConnectorTypeOIDC, connector.Type)
|
||||
assert.Equal(t, "https://okta.example.com", connector.Issuer)
|
||||
|
||||
// Verify the request body contains expected fields
|
||||
var reqBody map[string]any
|
||||
err = json.Unmarshal([]byte(mockClient.reqBody), &reqBody)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Okta", reqBody["name"])
|
||||
assert.Equal(t, "https://okta.example.com", reqBody["issuer"])
|
||||
assert.Equal(t, "client-123", reqBody["clientId"])
|
||||
}
|
||||
|
||||
func TestZitadelManager_AddLDAPConnector(t *testing.T) {
|
||||
mockResponse := `{"id": "ldap-456", "details": {"sequence": "1", "creationDate": "2024-01-01T00:00:00Z", "changeDate": "2024-01-01T00:00:00Z", "resourceOwner": "org-1"}}`
|
||||
|
||||
mockClient := &mockHTTPClient{
|
||||
code: http.StatusCreated,
|
||||
resBody: mockResponse,
|
||||
}
|
||||
|
||||
manager := &ZitadelManager{
|
||||
managementEndpoint: "https://zitadel.example.com/management/v1",
|
||||
httpClient: mockClient,
|
||||
credentials: &mockCredentials{token: "test-token"},
|
||||
helper: JsonParser{},
|
||||
}
|
||||
|
||||
config := LDAPConnectorConfig{
|
||||
Name: "Corporate LDAP",
|
||||
Servers: []string{"ldap://ldap.example.com:389"},
|
||||
BaseDN: "dc=example,dc=com",
|
||||
BindDN: "cn=admin,dc=example,dc=com",
|
||||
BindPassword: "admin-password",
|
||||
Attributes: LDAPAttributes{
|
||||
IDAttribute: "uid",
|
||||
EmailAttribute: "mail",
|
||||
},
|
||||
}
|
||||
|
||||
connector, err := manager.AddLDAPConnector(context.Background(), config)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ldap-456", connector.ID)
|
||||
assert.Equal(t, "Corporate LDAP", connector.Name)
|
||||
assert.Equal(t, ConnectorTypeLDAP, connector.Type)
|
||||
assert.Equal(t, []string{"ldap://ldap.example.com:389"}, connector.Servers)
|
||||
}
|
||||
|
||||
func TestZitadelManager_AddSAMLConnector(t *testing.T) {
|
||||
mockResponse := `{"id": "saml-789", "details": {"sequence": "1", "creationDate": "2024-01-01T00:00:00Z", "changeDate": "2024-01-01T00:00:00Z", "resourceOwner": "org-1"}}`
|
||||
|
||||
mockClient := &mockHTTPClient{
|
||||
code: http.StatusCreated,
|
||||
resBody: mockResponse,
|
||||
}
|
||||
|
||||
manager := &ZitadelManager{
|
||||
managementEndpoint: "https://zitadel.example.com/management/v1",
|
||||
httpClient: mockClient,
|
||||
credentials: &mockCredentials{token: "test-token"},
|
||||
helper: JsonParser{},
|
||||
}
|
||||
|
||||
config := SAMLConnectorConfig{
|
||||
Name: "Enterprise SAML",
|
||||
MetadataURL: "https://idp.example.com/metadata.xml",
|
||||
}
|
||||
|
||||
connector, err := manager.AddSAMLConnector(context.Background(), config)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "saml-789", connector.ID)
|
||||
assert.Equal(t, "Enterprise SAML", connector.Name)
|
||||
assert.Equal(t, ConnectorTypeSAML, connector.Type)
|
||||
}
|
||||
|
||||
func TestZitadelManager_AddSAMLConnector_RequiresMetadata(t *testing.T) {
|
||||
manager := &ZitadelManager{
|
||||
managementEndpoint: "https://zitadel.example.com/management/v1",
|
||||
httpClient: &mockHTTPClient{},
|
||||
credentials: &mockCredentials{token: "test-token"},
|
||||
helper: JsonParser{},
|
||||
}
|
||||
|
||||
config := SAMLConnectorConfig{
|
||||
Name: "Invalid SAML",
|
||||
// Neither MetadataXML nor MetadataURL provided
|
||||
}
|
||||
|
||||
_, err := manager.AddSAMLConnector(context.Background(), config)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "metadataXml or metadataUrl must be provided")
|
||||
}
|
||||
|
||||
func TestZitadelManager_ListConnectors(t *testing.T) {
|
||||
mockResponse := `{
|
||||
"result": [
|
||||
{
|
||||
"id": "oidc-1",
|
||||
"name": "Google",
|
||||
"state": "IDP_STATE_ACTIVE",
|
||||
"type": "IDP_TYPE_OIDC",
|
||||
"oidc": {"issuer": "https://accounts.google.com", "clientId": "google-client"}
|
||||
},
|
||||
{
|
||||
"id": "ldap-1",
|
||||
"name": "AD",
|
||||
"state": "IDP_STATE_INACTIVE",
|
||||
"type": "IDP_TYPE_LDAP",
|
||||
"ldap": {"servers": ["ldap://ad.example.com:389"], "baseDn": "dc=example,dc=com"}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
mockClient := &mockHTTPClient{
|
||||
code: http.StatusOK,
|
||||
resBody: mockResponse,
|
||||
}
|
||||
|
||||
manager := &ZitadelManager{
|
||||
managementEndpoint: "https://zitadel.example.com/management/v1",
|
||||
httpClient: mockClient,
|
||||
credentials: &mockCredentials{token: "test-token"},
|
||||
helper: JsonParser{},
|
||||
}
|
||||
|
||||
connectors, err := manager.ListConnectors(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, connectors, 2)
|
||||
|
||||
assert.Equal(t, "oidc-1", connectors[0].ID)
|
||||
assert.Equal(t, "Google", connectors[0].Name)
|
||||
assert.Equal(t, "active", connectors[0].State)
|
||||
assert.Equal(t, ConnectorTypeOIDC, connectors[0].Type)
|
||||
assert.Equal(t, "https://accounts.google.com", connectors[0].Issuer)
|
||||
|
||||
assert.Equal(t, "ldap-1", connectors[1].ID)
|
||||
assert.Equal(t, "AD", connectors[1].Name)
|
||||
assert.Equal(t, "inactive", connectors[1].State)
|
||||
assert.Equal(t, ConnectorTypeLDAP, connectors[1].Type)
|
||||
assert.Equal(t, []string{"ldap://ad.example.com:389"}, connectors[1].Servers)
|
||||
}
|
||||
|
||||
func TestZitadelManager_GetConnector(t *testing.T) {
|
||||
mockResponse := `{
|
||||
"idp": {
|
||||
"id": "oidc-123",
|
||||
"name": "Okta",
|
||||
"state": "IDP_STATE_ACTIVE",
|
||||
"type": "IDP_TYPE_OIDC",
|
||||
"oidc": {"issuer": "https://okta.example.com", "clientId": "client-123"}
|
||||
}
|
||||
}`
|
||||
|
||||
mockClient := &mockHTTPClient{
|
||||
code: http.StatusOK,
|
||||
resBody: mockResponse,
|
||||
}
|
||||
|
||||
manager := &ZitadelManager{
|
||||
managementEndpoint: "https://zitadel.example.com/management/v1",
|
||||
httpClient: mockClient,
|
||||
credentials: &mockCredentials{token: "test-token"},
|
||||
helper: JsonParser{},
|
||||
}
|
||||
|
||||
connector, err := manager.GetConnector(context.Background(), "oidc-123")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "oidc-123", connector.ID)
|
||||
assert.Equal(t, "Okta", connector.Name)
|
||||
assert.Equal(t, ConnectorTypeOIDC, connector.Type)
|
||||
assert.Equal(t, "https://okta.example.com", connector.Issuer)
|
||||
}
|
||||
|
||||
func TestZitadelManager_DeleteConnector(t *testing.T) {
|
||||
mockClient := &mockHTTPClient{
|
||||
code: http.StatusOK,
|
||||
resBody: "{}",
|
||||
}
|
||||
|
||||
manager := &ZitadelManager{
|
||||
managementEndpoint: "https://zitadel.example.com/management/v1",
|
||||
httpClient: mockClient,
|
||||
credentials: &mockCredentials{token: "test-token"},
|
||||
helper: JsonParser{},
|
||||
}
|
||||
|
||||
err := manager.DeleteConnector(context.Background(), "oidc-123")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestZitadelManager_ActivateConnector(t *testing.T) {
|
||||
mockClient := &mockHTTPClient{
|
||||
code: http.StatusOK,
|
||||
resBody: "{}",
|
||||
}
|
||||
|
||||
manager := &ZitadelManager{
|
||||
managementEndpoint: "https://zitadel.example.com/management/v1",
|
||||
httpClient: mockClient,
|
||||
credentials: &mockCredentials{token: "test-token"},
|
||||
helper: JsonParser{},
|
||||
}
|
||||
|
||||
err := manager.ActivateConnector(context.Background(), "oidc-123")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the request body
|
||||
var reqBody map[string]string
|
||||
err = json.Unmarshal([]byte(mockClient.reqBody), &reqBody)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "oidc-123", reqBody["idpId"])
|
||||
}
|
||||
|
||||
func TestZitadelManager_DeactivateConnector(t *testing.T) {
|
||||
mockClient := &mockHTTPClient{
|
||||
code: http.StatusOK,
|
||||
resBody: "{}",
|
||||
}
|
||||
|
||||
manager := &ZitadelManager{
|
||||
managementEndpoint: "https://zitadel.example.com/management/v1",
|
||||
httpClient: mockClient,
|
||||
credentials: &mockCredentials{token: "test-token"},
|
||||
helper: JsonParser{},
|
||||
}
|
||||
|
||||
err := manager.DeactivateConnector(context.Background(), "oidc-123")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestNormalizeState(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"IDP_STATE_ACTIVE", "active"},
|
||||
{"IDP_STATE_INACTIVE", "inactive"},
|
||||
{"custom", "custom"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, normalizeState(tc.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeType(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected ConnectorType
|
||||
}{
|
||||
{"IDP_TYPE_OIDC", ConnectorTypeOIDC},
|
||||
{"IDP_TYPE_OIDC_GENERIC", ConnectorTypeOIDC},
|
||||
{"IDP_TYPE_LDAP", ConnectorTypeLDAP},
|
||||
{"IDP_TYPE_SAML", ConnectorTypeSAML},
|
||||
{"CUSTOM", ConnectorType("CUSTOM")},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, normalizeType(tc.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mockCredentials is a mock implementation of ManagerCredentials for testing
|
||||
type mockCredentials struct {
|
||||
token string
|
||||
}
|
||||
|
||||
func (m *mockCredentials) Authenticate(ctx context.Context) (JWTToken, error) {
|
||||
return JWTToken{AccessToken: m.token}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user