robust_client_socket 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/test_*.rb"]
10
+ end
11
+
12
+ task default: :test
@@ -0,0 +1,67 @@
1
+ module RobustClientSocket
2
+ module Configuration
3
+ MIN_KEY_SIZE = 2048
4
+
5
+ attr_reader :configuration, :configured
6
+
7
+ def configure
8
+ @configuration ||= ConfigStore.new
9
+ yield(configuration)
10
+ validate_keys_security!
11
+
12
+ @configured = true
13
+ end
14
+
15
+ def correct_configuration?
16
+ return false unless configuration.services.is_a?(Hash)
17
+ return false if configuration.services.empty?
18
+ return false if configuration.client_name.nil?
19
+
20
+ configuration.services.all? do |_, creds|
21
+ creds.key?(:base_uri) && creds.key?(:public_key)
22
+ end
23
+ end
24
+
25
+ def configured?
26
+ !!@configured && correct_configuration?
27
+ end
28
+
29
+ private
30
+
31
+ def validate_keys_security!
32
+ configuration.services.each do |service_name, creds|
33
+ next unless creds[:public_key]
34
+
35
+ key = OpenSSL::PKey::RSA.new(creds[:public_key])
36
+ key_bits = key.n.num_bits
37
+
38
+ if key_bits < MIN_KEY_SIZE
39
+ raise SecurityError,
40
+ "RSA key size for #{service_name} (#{key_bits} bits) below minimum (#{MIN_KEY_SIZE} bits)"
41
+ end
42
+
43
+ raise SecurityError, "Invalid public key for #{service_name}" unless key.public?
44
+ rescue OpenSSL::PKey::RSAError => e
45
+ raise SecurityError, "Invalid public key for #{service_name}: #{e.message}"
46
+ end
47
+ end
48
+ end
49
+
50
+ class ConfigStore
51
+ attr_reader :services
52
+ attr_accessor :client_name, :header_name
53
+
54
+ def initialize
55
+ @services = {}
56
+ @client_name = nil
57
+ end
58
+
59
+ def method_missing(name, *args) # rubocop:disable Style/MissingRespondToMissing
60
+ if name.end_with?('=')
61
+ @services[name.to_s.delete_suffix('=').to_sym] = args.first.is_a?(Hash) && args.pop
62
+ else
63
+ super
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,90 @@
1
+ module RobustClientSocket
2
+ module HTTP
3
+ class Client
4
+ include Helpers
5
+ include HTTParty
6
+ include HTTPartyOverrides
7
+
8
+ singleton_class.attr_accessor :credentials, :client_name, :header_name
9
+
10
+ InsecureConnectionError = Class.new(StandardError)
11
+ InvalidCredentialsError = Class.new(StandardError)
12
+
13
+ class << self
14
+ def init(credentials:, client_name:, header_name: nil)
15
+ validate_credentials!(credentials)
16
+
17
+ if credentials.fetch(:ssl_verify, false)
18
+ configure_ssl!(credentials)
19
+ enforce_https!(credentials)
20
+ end
21
+
22
+ self.credentials = credentials
23
+ self.client_name = client_name
24
+ self.header_name = header_name
25
+
26
+ base_uri credentials[:base_uri]
27
+ headers robust_headers
28
+ configure_timeouts(credentials)
29
+ end
30
+
31
+ private
32
+
33
+ def validate_credentials!(credentials)
34
+ required_keys = [:base_uri, :public_key]
35
+ missing = required_keys - credentials.keys
36
+
37
+ raise InvalidCredentialsError, "Missing keys: #{missing.join(', ')}" if missing.any?
38
+ raise InvalidCredentialsError, "base_uri cannot be empty" if credentials[:base_uri].to_s.strip.empty?
39
+ raise InvalidCredentialsError, "public_key cannot be empty" if credentials[:public_key].to_s.strip.empty?
40
+ end
41
+
42
+ def enforce_https!(credentials)
43
+ return if credentials[:base_uri].start_with?('https://')
44
+ return unless production?
45
+
46
+ raise InsecureConnectionError,
47
+ "HTTPS required in production. Use https:// instead of #{credentials[:base_uri]}"
48
+ end
49
+
50
+ def configure_ssl!(credentials)
51
+ default_options.update(
52
+ verify: true,
53
+ ssl_version: :TLSv1_2,
54
+ ciphers: ssl_ciphers(credentials),
55
+ verify_mode: OpenSSL::SSL::VERIFY_PEER
56
+ )
57
+ end
58
+
59
+ def ssl_ciphers(credentials)
60
+ ciphers = credentials.fetch(:ciphers,
61
+ %w[ECDHE-RSA-AES128-GCM-SHA256
62
+ ECDHE-RSA-AES256-GCM-SHA384
63
+ ECDHE-ECDSA-AES128-GCM-SHA256
64
+ ECDHE-ECDSA-AES256-GCM-SHA384].join(':')
65
+ )
66
+
67
+ return ciphers if ciphers.is_a?(String)
68
+ return ArgumentError, "Ciphers must be Array or String" unless ciphers.is_a?(Array)
69
+
70
+ ciphers.join(':')
71
+ end
72
+
73
+ def configure_timeouts(credentials)
74
+ default_options.update(
75
+ timeout: credentials.fetch(:timeout, 10),
76
+ open_timeout: credentials.fetch(:open_timeout, 5)
77
+ )
78
+ end
79
+
80
+ def production?
81
+ if defined? Rails
82
+ Rails.env.production?
83
+ else
84
+ ENV.fetch("RACK_ENV", "development") == 'production'
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,68 @@
1
+ module RobustClientSocket
2
+ module HTTP
3
+ module Helpers
4
+ def self.included(base)
5
+ base.extend(PrivateClassMethods)
6
+ base.private_class_method(*PrivateClassMethods.instance_methods(false))
7
+ end
8
+ end
9
+
10
+ module PrivateClassMethods
11
+ MIN_KEY_SIZE = 2048
12
+
13
+ def robust_headers
14
+ {
15
+ 'Content-Type' => 'application/json',
16
+ 'Accept' => 'application/json',
17
+ 'User-Agent' => "RobustClientSocket/#{RobustClientSocket::VERSION}"
18
+ }
19
+ end
20
+
21
+ def secure_token
22
+ encrypted_data(app_token)
23
+ end
24
+
25
+ def encrypted_data(data)
26
+ validate_key_security!
27
+
28
+ encrypted = rsa_key.public_encrypt(
29
+ data,
30
+ OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING
31
+ )
32
+
33
+ ::Base64.strict_encode64(encrypted)
34
+ rescue OpenSSL::PKey::RSAError => e
35
+ raise SecurityError, "Encryption failed: #{e.message}"
36
+ end
37
+
38
+ def rsa_key
39
+ @rsa_key ||= begin
40
+ key = OpenSSL::PKey::RSA.new(public_key)
41
+ raise SecurityError, "Invalid public key" unless key.public?
42
+ key
43
+ end
44
+ end
45
+
46
+ def validate_key_security!
47
+ key_bits = rsa_key.n.num_bits
48
+
49
+ if key_bits < MIN_KEY_SIZE
50
+ raise SecurityError,
51
+ "RSA key size (#{key_bits} bits) below minimum (#{MIN_KEY_SIZE} bits)"
52
+ end
53
+ end
54
+
55
+ def public_key
56
+ credentials[:public_key]
57
+ end
58
+
59
+ def app_token
60
+ "#{client_name}_#{time_now_in_utc}"
61
+ end
62
+
63
+ def time_now_in_utc
64
+ Time.now.utc.to_i
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,20 @@
1
+ module RobustClientSocket
2
+ module HTTP
3
+ # This allows us to set dynamic headers every time we make a request
4
+ module HTTPartyOverrides
5
+ DEFAULT_HEADER_NAME = 'Secure-Token'.freeze
6
+
7
+ def self.included(base)
8
+ base.singleton_class.prepend(ClassMethods)
9
+ base.private_class_method(*ClassMethods.instance_methods(false))
10
+ end
11
+
12
+ module ClassMethods
13
+ def perform_request(http_method, path, options, &)
14
+ headers[header_name || DEFAULT_HEADER_NAME] = secure_token
15
+ super
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'version'
4
+ require_relative 'robust_client_socket/configuration'
5
+
6
+ module RobustClientSocket
7
+ extend RobustClientSocket::Configuration
8
+
9
+ module_function
10
+
11
+ def load! # rubocop:disable Metrics/AbcSize
12
+ raise 'You must configure RobustClientSocket first!' unless configured?
13
+
14
+ require 'openssl'
15
+ require 'httparty'
16
+ require 'base64'
17
+ require 'oj'
18
+
19
+ require_relative 'robust_client_socket/http/httparty_overrides'
20
+ require_relative 'robust_client_socket/http/helpers'
21
+ require_relative 'robust_client_socket/http/client'
22
+
23
+ configuration.services.each do |key, value|
24
+ client = Class.new(RobustClientSocket::HTTP::Client)
25
+ client.init(credentials: value, client_name: configuration.client_name, header_name: configuration.header_name)
26
+ const_set(key.to_s.split('_').map(&:capitalize).join, client)
27
+ end
28
+ end
29
+ end
data/lib/version.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobustClientSocket
4
+ VERSION = '0.5.2'
5
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "./lib/version.rb"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "robust_client_socket"
7
+ spec.version = RobustClientSocket::VERSION
8
+ spec.authors = ["Taras Zhuk"]
9
+ spec.email = ["tee0zed@gmail.com"]
10
+ spec.summary = "Robust Client Socket"
11
+ spec.description = "Methods that should be used to interact with Robust inner ecosystem."
12
+ spec.required_ruby_version = ">= 2.7.7"
13
+
14
+ # Specify which files should be added to the gem when it is released.
15
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
16
+ spec.files = Dir.chdir(__dir__) do
17
+ `git ls-files -z`.split("\x0").reject do |f|
18
+ (File.expand_path(f) == __FILE__) ||
19
+ f.start_with?(*%w[features/ .git .circleci Gemfile])
20
+ end
21
+ end
22
+ spec.require_paths = %w[lib config]
23
+ spec.add_dependency 'oj'
24
+ spec.add_dependency 'httparty'
25
+ spec.add_dependency 'rspec'
26
+ spec.add_dependency 'rake'
27
+
28
+ # Uncomment to register a new dependency of your gem
29
+ # spec.add_dependency "example-gem", "~> 1.0"
30
+
31
+ # For more information and examples about making a new gem, check out our
32
+ # guide at: https://bundler.io/guides/creating_gem.html
33
+ end
@@ -0,0 +1,137 @@
1
+ require 'spec_helper'
2
+ require 'httparty'
3
+ require './lib/version.rb'
4
+ require './lib/robust_client_socket/http/helpers'
5
+ require './lib/robust_client_socket/http/httparty_overrides'
6
+ require './lib/robust_client_socket/http/client'
7
+
8
+ RSpec.describe RobustClientSocket::HTTP::Client do
9
+ let(:credentials) do
10
+ {
11
+ base_uri: 'https://example.com',
12
+ public_key: OpenSSL::PKey::RSA.new(2048).public_key.to_s
13
+ }
14
+ end
15
+ let(:client_name) { 'test_client' }
16
+ let(:header_name) { 'Custom-Token' }
17
+
18
+ after(:each) do
19
+ # Reset HTTParty default_options to avoid test pollution
20
+ described_class.default_options.delete(:verify)
21
+ described_class.default_options.delete(:verify_mode)
22
+ described_class.default_options.delete(:ssl_version)
23
+ described_class.default_options.delete(:ciphers)
24
+ end
25
+
26
+ describe '.init' do
27
+ context 'with valid HTTPS credentials' do
28
+ it 'sets credentials' do
29
+ described_class.init(credentials: credentials, client_name: client_name)
30
+ expect(described_class.credentials).to eq(credentials)
31
+ end
32
+
33
+ it 'sets client_name' do
34
+ described_class.init(credentials: credentials, client_name: client_name)
35
+ expect(described_class.client_name).to eq(client_name)
36
+ end
37
+
38
+ it 'sets custom header_name' do
39
+ described_class.init(credentials: credentials, client_name: client_name, header_name: header_name)
40
+ expect(described_class.header_name).to eq(header_name)
41
+ end
42
+
43
+ it 'sets base_uri' do
44
+ described_class.init(credentials: credentials, client_name: client_name)
45
+ expect(described_class.base_uri).to eq('https://example.com')
46
+ end
47
+
48
+ it 'configures SSL verification when ssl_verify is enabled' do
49
+ ssl_credentials = credentials.merge(ssl_verify: true)
50
+ described_class.init(credentials: ssl_credentials, client_name: client_name)
51
+ expect(described_class.default_options[:verify]).to be true
52
+ expect(described_class.default_options[:verify_mode]).to eq(OpenSSL::SSL::VERIFY_PEER)
53
+ end
54
+
55
+ it 'does not configure SSL verification when ssl_verify is not set' do
56
+ described_class.init(credentials: credentials, client_name: client_name)
57
+ expect(described_class.default_options[:verify]).to be_nil
58
+ end
59
+ end
60
+
61
+ context 'in production with non-HTTPS base_uri and ssl_verify enabled' do
62
+ let(:http_credentials) { credentials.merge(base_uri: 'http://example.com', ssl_verify: true) }
63
+
64
+ before do
65
+ allow(described_class).to receive(:production?).and_return(true)
66
+ end
67
+
68
+ it 'raises InsecureConnectionError' do
69
+ expect {
70
+ described_class.init(credentials: http_credentials, client_name: client_name)
71
+ }.to raise_error(RobustClientSocket::HTTP::Client::InsecureConnectionError, /HTTPS required in production/)
72
+ end
73
+ end
74
+
75
+ context 'in production with non-HTTPS base_uri and ssl_verify disabled' do
76
+ let(:http_credentials) { credentials.merge(base_uri: 'http://example.com', ssl_verify: false) }
77
+
78
+ before do
79
+ allow(described_class).to receive(:production?).and_return(true)
80
+ end
81
+
82
+ it 'allows HTTP when ssl_verify is false' do
83
+ expect {
84
+ described_class.init(credentials: http_credentials, client_name: client_name)
85
+ }.not_to raise_error
86
+ end
87
+ end
88
+
89
+ context 'in development with HTTP base_uri' do
90
+ let(:http_credentials) { credentials.merge(base_uri: 'http://example.com') }
91
+
92
+ before do
93
+ allow(described_class).to receive(:production?).and_return(false)
94
+ end
95
+
96
+ it 'allows HTTP' do
97
+ expect {
98
+ described_class.init(credentials: http_credentials, client_name: client_name)
99
+ }.not_to raise_error
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ RSpec.describe RobustClientSocket::HTTP::Client do
106
+ describe '.production?' do
107
+ context 'when Rails is defined' do
108
+ let(:rails_stub) { double('Rails') }
109
+ let(:rails_env) { double('env', production?: true) }
110
+
111
+ before do
112
+ stub_const('Rails', rails_stub)
113
+ allow(rails_stub).to receive(:env).and_return(rails_env)
114
+ end
115
+
116
+ it 'delegates to Rails.production?' do
117
+ expect(described_class.send(:production?)).to be true
118
+ end
119
+ end
120
+
121
+ context 'when Rails is not defined' do
122
+ before do
123
+ hide_const('Rails') if defined?(Rails)
124
+ end
125
+
126
+ it 'checks RACK_ENV for production' do
127
+ allow(ENV).to receive(:fetch).with('RACK_ENV', 'development').and_return('production')
128
+ expect(described_class.send(:production?)).to be true
129
+ end
130
+
131
+ it 'defaults to development' do
132
+ allow(ENV).to receive(:fetch).with('RACK_ENV', 'development').and_return('development')
133
+ expect(described_class.send(:production?)).to be false
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,31 @@
1
+ require 'spec_helper'
2
+ require './lib/robust_client_socket.rb'
3
+
4
+ RSpec.describe RobustClientSocket do
5
+ describe '.load' do
6
+ before do
7
+ RobustClientSocket.configure do |config|
8
+ config.service_one = {
9
+ base_uri: 'https://example1.com',
10
+ public_key: 'public_key'
11
+ }
12
+ config.service_two = {
13
+ base_uri: 'https://example2.com',
14
+ public_key: 'public_key'
15
+ }
16
+ end
17
+ end
18
+
19
+ it 'loads services as separate classes' do
20
+ RobustClientSocket.load!
21
+ expect(RobustClientSocket::ServiceOne).to be_a(Class)
22
+ expect(RobustClientSocket::ServiceTwo).to be_a(Class)
23
+ end
24
+
25
+ it 'loads services with correct base_uri' do
26
+ RobustClientSocket.load!
27
+ expect(RobustClientSocket::ServiceOne.base_uri).to eq('https://example1.com')
28
+ expect(RobustClientSocket::ServiceTwo.base_uri).to eq('https://example2.com')
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,131 @@
1
+ require 'spec_helper'
2
+ require './lib/robust_client_socket/configuration.rb'
3
+
4
+ RSpec.describe RobustClientSocket::Configuration do
5
+ let(:dummy_class) { Class.new { extend RobustClientSocket::Configuration } }
6
+
7
+ before { allow(dummy_class).to receive(:correct_configuration?).and_return(true) }
8
+
9
+ describe '#configure' do
10
+ let(:public_key) { OpenSSL::PKey::RSA.generate(2048).public_key.to_pem }
11
+
12
+ it 'yields the configuration object to the block' do
13
+ dummy_class.configure do |config|
14
+ config.client_name = 'test'
15
+ config.service = { base_uri: 'https://example.com', public_key: public_key }
16
+ expect(config).to be_a(RobustClientSocket::ConfigStore)
17
+ end
18
+ end
19
+
20
+ it 'sets the configured flag to true' do
21
+ dummy_class.configure do |config|
22
+ config.client_name = 'test'
23
+ config.service = { base_uri: 'https://example.com', public_key: public_key }
24
+ end
25
+ expect(dummy_class.configured?).to eq(true)
26
+ end
27
+
28
+ context 'with invalid key size' do
29
+ let(:small_key) { OpenSSL::PKey::RSA.generate(1024).public_key.to_pem }
30
+
31
+ it 'raises SecurityError' do
32
+ expect {
33
+ dummy_class.configure do |config|
34
+ config.client_name = 'test'
35
+ config.service = { base_uri: 'https://example.com', public_key: small_key }
36
+ end
37
+ }.to raise_error(SecurityError, /below minimum/)
38
+ end
39
+ end
40
+ end
41
+
42
+ describe '#configured?' do
43
+ let(:public_key) { OpenSSL::PKey::RSA.generate(2048).public_key.to_pem }
44
+
45
+ it 'returns false if not configured' do
46
+ expect(dummy_class.configured?).to eq(false)
47
+ end
48
+
49
+ it 'returns true if configured' do
50
+ dummy_class.configure do |config|
51
+ config.client_name = 'test'
52
+ config.service = { base_uri: 'https://example.com', public_key: public_key }
53
+ end
54
+ expect(dummy_class.configured?).to eq(true)
55
+ end
56
+ end
57
+
58
+ describe '#correct_configuration?' do
59
+ let(:public_key) { OpenSSL::PKey::RSA.generate(2048).public_key.to_pem }
60
+
61
+ before { allow(dummy_class).to receive(:correct_configuration?).and_call_original }
62
+
63
+ it 'returns false if services is empty' do
64
+ dummy_class.configure { |config| }
65
+ expect(dummy_class.correct_configuration?).to eq(false)
66
+ end
67
+
68
+ it 'returns false if creds is missing base_uri' do
69
+ dummy_class.configure { |config| config.service = { public_key: public_key } }
70
+ expect(dummy_class.correct_configuration?).to eq(false)
71
+ end
72
+
73
+ it 'returns false if creds is missing public_key' do
74
+ dummy_class.configure { |config| config.service = { base_uri: 'base_uri' } }
75
+ expect(dummy_class.correct_configuration?).to eq(false)
76
+ end
77
+
78
+ it 'returns true if creds is correct' do
79
+ dummy_class.configure do |config|
80
+ config.client_name = 'sample'
81
+ config.service = { base_uri: 'base_uri', public_key: public_key }
82
+ end
83
+ expect(dummy_class.correct_configuration?).to eq(true)
84
+ end
85
+ end
86
+ end
87
+
88
+ RSpec.describe RobustClientSocket::ConfigStore do
89
+ subject(:config_store) { described_class.new }
90
+
91
+ it 'has attribute keychain' do
92
+ config_store.service = { uri: 'uri' }
93
+ expect(config_store.services).to eq({ service: { uri: 'uri' } })
94
+ end
95
+
96
+ describe '#initialize' do
97
+ it 'initializes services as empty hash' do
98
+ expect(config_store.services).to eq({})
99
+ end
100
+
101
+ it 'initializes client_name as nil' do
102
+ expect(config_store.client_name).to be_nil
103
+ end
104
+ end
105
+
106
+ describe '#method_missing' do
107
+ context 'with setter method' do
108
+ it 'adds service to services hash' do
109
+ config_store.api_service = { base_uri: 'https://api.example.com', public_key: 'key' }
110
+ expect(config_store.services[:api_service]).to eq({ base_uri: 'https://api.example.com', public_key: 'key' })
111
+ end
112
+
113
+ it 'rejects non-hash values' do
114
+ config_store.api_service = 'string_value'
115
+ expect(config_store.services[:api_service]).to be false
116
+ end
117
+ end
118
+ end
119
+
120
+ describe 'accessors' do
121
+ it 'allows setting client_name' do
122
+ config_store.client_name = 'test_client'
123
+ expect(config_store.client_name).to eq('test_client')
124
+ end
125
+
126
+ it 'allows setting header_name' do
127
+ config_store.header_name = 'Custom-Header'
128
+ expect(config_store.header_name).to eq('Custom-Header')
129
+ end
130
+ end
131
+ end