package idp import ( "context" "errors" "fmt" "net/http" "github.com/dexidp/dex/storage" "github.com/google/uuid" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/idp/dex" "github.com/netbirdio/netbird/management/server/telemetry" ) const ( staticClientDashboard = "netbird-dashboard" staticClientCLI = "netbird-cli" defaultCLIRedirectURL1 = "http://localhost:53000/" defaultCLIRedirectURL2 = "http://localhost:54000/" defaultScopes = "openid profile email offline_access" defaultUserIDClaim = "sub" ) // EmbeddedIdPConfig contains configuration for the embedded Dex OIDC identity provider type EmbeddedIdPConfig struct { // Enabled indicates whether the embedded IDP is enabled Enabled bool // Issuer is the OIDC issuer URL (e.g., "http://localhost:3002/oauth2") Issuer string // Storage configuration for the IdP database Storage EmbeddedStorageConfig // DashboardRedirectURIs are the OAuth2 redirect URIs for the dashboard client DashboardRedirectURIs []string // DashboardRedirectURIs are the OAuth2 redirect URIs for the dashboard client CLIRedirectURIs []string // Owner is the initial owner/admin user (optional, can be nil) Owner *OwnerConfig // SignKeyRefreshEnabled enables automatic key rotation for signing keys SignKeyRefreshEnabled bool } // EmbeddedStorageConfig holds storage configuration for the embedded IdP. type EmbeddedStorageConfig struct { // Type is the storage type (currently only "sqlite3" is supported) Type string // Config contains type-specific configuration Config EmbeddedStorageTypeConfig } // EmbeddedStorageTypeConfig contains type-specific storage configuration. type EmbeddedStorageTypeConfig struct { // File is the path to the SQLite database file (for sqlite3 type) File string } // OwnerConfig represents the initial owner/admin user for the embedded IdP. type OwnerConfig struct { // Email is the user's email address (required) Email string // Hash is the bcrypt hash of the user's password (required) Hash string // Username is the display name for the user (optional, defaults to email) Username string } // ToYAMLConfig converts EmbeddedIdPConfig to dex.YAMLConfig. func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { if c.Issuer == "" { return nil, fmt.Errorf("issuer is required") } if c.Storage.Type == "" { c.Storage.Type = "sqlite3" } if c.Storage.Type == "sqlite3" && c.Storage.Config.File == "" { return nil, fmt.Errorf("storage file is required for sqlite3") } // Build CLI redirect URIs including the device callback (both relative and absolute) cliRedirectURIs := c.CLIRedirectURIs cliRedirectURIs = append(cliRedirectURIs, "/device/callback") cliRedirectURIs = append(cliRedirectURIs, c.Issuer+"/device/callback") cfg := &dex.YAMLConfig{ Issuer: c.Issuer, Storage: dex.Storage{ Type: c.Storage.Type, Config: map[string]interface{}{ "file": c.Storage.Config.File, }, }, Web: dex.Web{ AllowedOrigins: []string{"*"}, AllowedHeaders: []string{"Authorization", "Content-Type"}, }, OAuth2: dex.OAuth2{ SkipApprovalScreen: true, }, Frontend: dex.Frontend{ Issuer: "NetBird", Theme: "light", }, EnablePasswordDB: true, StaticClients: []storage.Client{ { ID: staticClientDashboard, Name: "NetBird Dashboard", Public: true, RedirectURIs: c.DashboardRedirectURIs, }, { ID: staticClientCLI, Name: "NetBird CLI", Public: true, RedirectURIs: cliRedirectURIs, }, }, } // Add owner user if provided if c.Owner != nil && c.Owner.Email != "" && c.Owner.Hash != "" { username := c.Owner.Username if username == "" { username = c.Owner.Email } cfg.StaticPasswords = []dex.Password{ { Email: c.Owner.Email, Hash: []byte(c.Owner.Hash), Username: username, UserID: uuid.New().String(), }, } } return cfg, nil } // Compile-time check that EmbeddedIdPManager implements Manager interface var _ Manager = (*EmbeddedIdPManager)(nil) // Compile-time check that EmbeddedIdPManager implements OAuthConfigProvider interface var _ OAuthConfigProvider = (*EmbeddedIdPManager)(nil) // OAuthConfigProvider defines the interface for OAuth configuration needed by auth flows. type OAuthConfigProvider interface { GetIssuer() string GetKeysLocation() string GetClientIDs() []string GetUserIDClaim() string GetTokenEndpoint() string GetDeviceAuthEndpoint() string GetAuthorizationEndpoint() string GetDefaultScopes() string GetCLIClientID() string GetCLIRedirectURLs() []string } // EmbeddedIdPManager implements the Manager interface using the embedded Dex IdP. type EmbeddedIdPManager struct { provider *dex.Provider appMetrics telemetry.AppMetrics config EmbeddedIdPConfig } // NewEmbeddedIdPManager creates a new instance of EmbeddedIdPManager from a configuration. // It instantiates the underlying Dex provider internally. // Note: Storage defaults are applied in config loading (applyEmbeddedIdPConfig) based on Datadir. func NewEmbeddedIdPManager(ctx context.Context, config *EmbeddedIdPConfig, appMetrics telemetry.AppMetrics) (*EmbeddedIdPManager, error) { if config == nil { return nil, fmt.Errorf("embedded IdP config is required") } // Apply defaults for CLI redirect URIs if len(config.CLIRedirectURIs) == 0 { config.CLIRedirectURIs = []string{defaultCLIRedirectURL1, defaultCLIRedirectURL2} } // there are some properties create when creating YAML config (e.g., auth clients) yamlConfig, err := config.ToYAMLConfig() if err != nil { return nil, err } provider, err := dex.NewProviderFromYAML(ctx, yamlConfig) if err != nil { return nil, fmt.Errorf("failed to create embedded IdP provider: %w", err) } log.WithContext(ctx).Infof("embedded Dex IDP initialized with issuer: %s", yamlConfig.Issuer) return &EmbeddedIdPManager{ provider: provider, appMetrics: appMetrics, config: *config, }, nil } // Handler returns the HTTP handler for serving OIDC requests. func (m *EmbeddedIdPManager) Handler() http.Handler { return m.provider.Handler() } // Stop gracefully shuts down the embedded IdP provider. func (m *EmbeddedIdPManager) Stop(ctx context.Context) error { return m.provider.Stop(ctx) } // UpdateUserAppMetadata updates user app metadata based on userID and metadata map. func (m *EmbeddedIdPManager) UpdateUserAppMetadata(ctx context.Context, userID string, appMetadata AppMetadata) error { // TODO: implement return nil } // GetUserDataByID requests user data from the embedded IdP via user ID. func (m *EmbeddedIdPManager) GetUserDataByID(ctx context.Context, userID string, appMetadata AppMetadata) (*UserData, error) { user, err := m.provider.GetUserByID(ctx, userID) if err != nil { if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountRequestError() } return nil, fmt.Errorf("failed to get user by ID: %w", err) } return &UserData{ Email: user.Email, Name: user.Username, ID: user.UserID, AppMetadata: appMetadata, }, nil } // GetAccount returns all the users for a given account. // Note: Embedded dex doesn't store account metadata, so this returns all users. func (m *EmbeddedIdPManager) GetAccount(ctx context.Context, accountID string) ([]*UserData, error) { users, err := m.provider.ListUsers(ctx) if err != nil { if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountRequestError() } return nil, fmt.Errorf("failed to list users: %w", err) } result := make([]*UserData, 0, len(users)) for _, user := range users { result = append(result, &UserData{ Email: user.Email, Name: user.Username, ID: user.UserID, AppMetadata: AppMetadata{ WTAccountID: accountID, }, }) } return result, nil } // GetAllAccounts gets all registered accounts with corresponding user data. // Note: Embedded dex doesn't store account metadata, so all users are indexed under UnsetAccountID. func (m *EmbeddedIdPManager) GetAllAccounts(ctx context.Context) (map[string][]*UserData, error) { if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountGetAllAccounts() } users, err := m.provider.ListUsers(ctx) if err != nil { if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountRequestError() } return nil, fmt.Errorf("failed to list users: %w", err) } indexedUsers := make(map[string][]*UserData) for _, user := range users { indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], &UserData{ Email: user.Email, Name: user.Username, ID: user.UserID, }) } return indexedUsers, nil } // CreateUser creates a new user in the embedded IdP. func (m *EmbeddedIdPManager) CreateUser(ctx context.Context, email, name, accountID, invitedByEmail string) (*UserData, error) { if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountCreateUser() } // Check if user already exists _, err := m.provider.GetUser(ctx, email) if err == nil { return nil, fmt.Errorf("user with email %s already exists", email) } if !errors.Is(err, storage.ErrNotFound) { if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountRequestError() } return nil, fmt.Errorf("failed to check existing user: %w", err) } // Generate a random password for the new user password := GeneratePassword(16, 2, 2, 2) // Create the user via provider (handles hashing and ID generation) // The provider returns an encoded user ID in Dex's format (base64 protobuf with connector ID) userID, err := m.provider.CreateUser(ctx, email, name, password) if err != nil { if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountRequestError() } return nil, fmt.Errorf("failed to create user in embedded IdP: %w", err) } log.WithContext(ctx).Debugf("created user %s in embedded IdP", email) return &UserData{ Email: email, Name: name, ID: userID, Password: password, AppMetadata: AppMetadata{ WTAccountID: accountID, WTInvitedBy: invitedByEmail, }, }, nil } // GetUserByEmail searches users with a given email. func (m *EmbeddedIdPManager) GetUserByEmail(ctx context.Context, email string) ([]*UserData, error) { user, err := m.provider.GetUser(ctx, email) if err != nil { if errors.Is(err, storage.ErrNotFound) { return nil, nil // Return empty slice for not found } if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountRequestError() } return nil, fmt.Errorf("failed to get user by email: %w", err) } return []*UserData{ { Email: user.Email, Name: user.Username, ID: user.UserID, }, }, nil } // CreateUserWithPassword creates a new user in the embedded IdP with a provided password. // Unlike CreateUser which auto-generates a password, this method uses the provided password. // This is useful for instance setup where the user provides their own password. func (m *EmbeddedIdPManager) CreateUserWithPassword(ctx context.Context, email, password, name string) (*UserData, error) { if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountCreateUser() } // Check if user already exists _, err := m.provider.GetUser(ctx, email) if err == nil { return nil, fmt.Errorf("user with email %s already exists", email) } if !errors.Is(err, storage.ErrNotFound) { if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountRequestError() } return nil, fmt.Errorf("failed to check existing user: %w", err) } // Create the user via provider with the provided password userID, err := m.provider.CreateUser(ctx, email, name, password) if err != nil { if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountRequestError() } return nil, fmt.Errorf("failed to create user in embedded IdP: %w", err) } log.WithContext(ctx).Debugf("created user %s in embedded IdP with provided password", email) return &UserData{ Email: email, Name: name, ID: userID, }, nil } // InviteUserByID resends an invitation to a user. func (m *EmbeddedIdPManager) InviteUserByID(ctx context.Context, userID string) error { // TODO: implement return fmt.Errorf("not implemented") } // DeleteUser deletes a user from the embedded IdP by user ID. func (m *EmbeddedIdPManager) DeleteUser(ctx context.Context, userID string) error { if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountDeleteUser() } // Get user by ID to retrieve email (provider.DeleteUser requires email) user, err := m.provider.GetUserByID(ctx, userID) if err != nil { if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountRequestError() } return fmt.Errorf("failed to get user for deletion: %w", err) } err = m.provider.DeleteUser(ctx, user.Email) if err != nil { if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountRequestError() } return fmt.Errorf("failed to delete user from embedded IdP: %w", err) } log.WithContext(ctx).Debugf("deleted user %s from embedded IdP", user.Email) 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) { return m.provider.CreateConnector(ctx, cfg) } // GetConnector retrieves an identity provider connector by ID. func (m *EmbeddedIdPManager) GetConnector(ctx context.Context, id string) (*dex.ConnectorConfig, error) { return m.provider.GetConnector(ctx, id) } // ListConnectors returns all identity provider connectors. func (m *EmbeddedIdPManager) ListConnectors(ctx context.Context) ([]*dex.ConnectorConfig, error) { return m.provider.ListConnectors(ctx) } // UpdateConnector updates an existing identity provider connector. 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) } // DeleteConnector removes an identity provider connector. func (m *EmbeddedIdPManager) DeleteConnector(ctx context.Context, id string) error { return m.provider.DeleteConnector(ctx, id) } // GetIssuer returns the OIDC issuer URL. func (m *EmbeddedIdPManager) GetIssuer() string { return m.provider.GetIssuer() } // GetTokenEndpoint returns the OAuth2 token endpoint URL. func (m *EmbeddedIdPManager) GetTokenEndpoint() string { return m.provider.GetTokenEndpoint() } // GetDeviceAuthEndpoint returns the OAuth2 device authorization endpoint URL. func (m *EmbeddedIdPManager) GetDeviceAuthEndpoint() string { return m.provider.GetDeviceAuthEndpoint() } // GetAuthorizationEndpoint returns the OAuth2 authorization endpoint URL. func (m *EmbeddedIdPManager) GetAuthorizationEndpoint() string { return m.provider.GetAuthorizationEndpoint() } // GetDefaultScopes returns the default OAuth2 scopes for authentication. func (m *EmbeddedIdPManager) GetDefaultScopes() string { return defaultScopes } // GetCLIClientID returns the client ID for CLI authentication. func (m *EmbeddedIdPManager) GetCLIClientID() string { return staticClientCLI } // GetCLIRedirectURLs returns the redirect URLs configured for the CLI client. func (m *EmbeddedIdPManager) GetCLIRedirectURLs() []string { if len(m.config.CLIRedirectURIs) == 0 { return []string{defaultCLIRedirectURL1, defaultCLIRedirectURL2} } return m.config.CLIRedirectURIs } // GetKeysLocation returns the JWKS endpoint URL for token validation. func (m *EmbeddedIdPManager) GetKeysLocation() string { return m.provider.GetKeysLocation() } // GetClientIDs returns the OAuth2 client IDs configured for this provider. func (m *EmbeddedIdPManager) GetClientIDs() []string { return []string{staticClientDashboard, staticClientCLI} } // GetUserIDClaim returns the JWT claim name used for user identification. func (m *EmbeddedIdPManager) GetUserIDClaim() string { return defaultUserIDClaim }