duo_api 1.4.0 → 1.5.0

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.
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'erb'
5
+ require 'json'
6
+ require 'openssl'
7
+ require 'net/https'
8
+ require 'time'
9
+ require 'uri'
10
+
11
+ ##
12
+ # A Ruby implementation of the Duo API
13
+ #
14
+ class DuoApi
15
+ attr_accessor :ca_file
16
+ attr_reader :default_params
17
+
18
+ VERSION = Gem.loaded_specs['duo_api'] ? Gem.loaded_specs['duo_api'].version : '0.0.0'
19
+
20
+ # Constants for handling rate limit backoff
21
+ MAX_BACKOFF_WAIT_SECS = 32
22
+ INITIAL_BACKOFF_WAIT_SECS = 1
23
+ BACKOFF_FACTOR = 2
24
+
25
+ def initialize(ikey, skey, host, proxy = nil, ca_file: nil, default_params: {})
26
+ @ikey = ikey
27
+ @skey = skey
28
+ @host = host
29
+ @proxy_str = proxy
30
+ if proxy.nil?
31
+ @proxy = []
32
+ else
33
+ proxy_uri = URI.parse proxy
34
+ @proxy = [
35
+ proxy_uri.host,
36
+ proxy_uri.port,
37
+ proxy_uri.user,
38
+ proxy_uri.password
39
+ ]
40
+ end
41
+ @ca_file = ca_file ||
42
+ File.join(File.dirname(__FILE__), '..', '..', 'ca_certs.pem')
43
+ @default_params = default_params.transform_keys(&:to_sym)
44
+ end
45
+
46
+ def default_params=(default_params)
47
+ @default_params = default_params.transform_keys(&:to_sym)
48
+ end
49
+
50
+ # Basic authenticated request returning raw Net::HTTPResponse object
51
+ def request(method, path, params = {}, additional_headers = nil)
52
+ # Merge default params with provided params
53
+ params = @default_params.merge(params.transform_keys(&:to_sym))
54
+
55
+ # Determine if params should be in a JSON request body
56
+ params_go_in_body = %w[POST PUT PATCH].include?(method)
57
+ if params_go_in_body
58
+ body = canon_json(params)
59
+ params = {}
60
+ else
61
+ body = ''
62
+ end
63
+
64
+ # Construct the request URI
65
+ uri = request_uri(path, params)
66
+
67
+ # Sign the request
68
+ current_date, signed = sign(method, uri.host, path, params, body, additional_headers)
69
+
70
+ # Create the HTTP request object
71
+ request = Net::HTTP.const_get(method.capitalize).new(uri.to_s)
72
+ request.basic_auth(@ikey, signed)
73
+ request['Date'] = current_date
74
+ request['User-Agent'] = "duo_api_ruby/#{VERSION}"
75
+
76
+ # Set Content-Type and request body for JSON requests
77
+ if params_go_in_body
78
+ request['Content-Type'] = 'application/json'
79
+ request.body = body
80
+ end
81
+
82
+ # Start the HTTP session
83
+ Net::HTTP.start(
84
+ uri.host, uri.port, *@proxy,
85
+ use_ssl: true, ca_file: @ca_file,
86
+ verify_mode: OpenSSL::SSL::VERIFY_PEER
87
+ ) do |http|
88
+ wait_secs = INITIAL_BACKOFF_WAIT_SECS
89
+ loop do
90
+ resp = http.request(request)
91
+
92
+ # Check if the response is rate-limited and handle backoff
93
+ return resp if !resp.is_a?(Net::HTTPTooManyRequests) || (wait_secs > MAX_BACKOFF_WAIT_SECS)
94
+
95
+ random_offset = rand
96
+ sleep(wait_secs + random_offset)
97
+ wait_secs *= BACKOFF_FACTOR
98
+ end
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ # Encode a key-value pair for a URL
105
+ def encode_key_val(key, val)
106
+ key = ERB::Util.url_encode(key.to_s)
107
+ value = ERB::Util.url_encode(val.to_s)
108
+ "#{key}=#{value}"
109
+ end
110
+
111
+ # Build a canonical parameter string
112
+ def canon_params(params_hash = nil)
113
+ return '' if params_hash.nil?
114
+
115
+ params_hash.transform_keys(&:to_s).sort.map do |k, v|
116
+ # When value an array, repeat key for each unique value in sorted array
117
+ if v.is_a?(Array)
118
+ if v.count.positive?
119
+ v.sort.uniq.map{ |vn| encode_key_val(k, vn) }.join('&')
120
+ else
121
+ encode_key_val(k, '')
122
+ end
123
+ else
124
+ encode_key_val(k, v)
125
+ end
126
+ end.join('&')
127
+ end
128
+
129
+ # Generate a canonical JSON body
130
+ def canon_json(params_hash = nil)
131
+ return '' if params_hash.nil?
132
+
133
+ JSON.generate(params_hash.sort.to_h)
134
+ end
135
+
136
+ # Canonicalize additional headers for signing
137
+ def canon_x_duo_headers(additional_headers)
138
+ additional_headers ||= {}
139
+
140
+ unless additional_headers.none?{ |k, v| k.nil? || v.nil? }
141
+ raise(HeaderError, 'Not allowed "nil" as a header name or value')
142
+ end
143
+
144
+ canon_list = []
145
+ added_headers = []
146
+ additional_headers.keys.sort.each do |header_name|
147
+ header_name_lowered = header_name.downcase
148
+ header_value = additional_headers[header_name]
149
+ validate_additional_header(header_name_lowered, header_value, added_headers)
150
+ canon_list.append(header_name_lowered, header_value)
151
+ added_headers.append(header_name_lowered)
152
+ end
153
+
154
+ canon = canon_list.join("\x00")
155
+ OpenSSL::Digest::SHA512.hexdigest(canon)
156
+ end
157
+
158
+ # Validate additional headers to ensure they meet requirements
159
+ def validate_additional_header(header_name, value, added_headers)
160
+ header_name.downcase!
161
+ raise(HeaderError, 'Not allowed "Null" character in header name') if header_name.include?("\x00")
162
+ raise(HeaderError, 'Not allowed "Null" character in header value') if value.include?("\x00")
163
+ raise(HeaderError, 'Additional headers must start with \'X-Duo-\'') unless header_name.start_with?('x-duo-')
164
+ raise(HeaderError, "Duplicate header passed, header=#{header_name}") if added_headers.include?(header_name)
165
+ end
166
+
167
+ # Construct the request URI
168
+ def request_uri(path, params = nil)
169
+ u = "https://#{@host}#{path}"
170
+ u += "?#{canon_params(params)}" unless params.nil?
171
+ URI.parse(u)
172
+ end
173
+
174
+ # Create a canonical string for signing requests
175
+ def canonicalize(method, host, path, params, body = '', additional_headers = nil, options: {})
176
+ # options[:date] being passed manually is specifically for tests
177
+ date = options[:date] || Time.now.rfc2822
178
+ canon = [
179
+ date,
180
+ method.upcase,
181
+ host.downcase,
182
+ path,
183
+ canon_params(params),
184
+ OpenSSL::Digest::SHA512.hexdigest(body),
185
+ canon_x_duo_headers(additional_headers)
186
+ ]
187
+ [date, canon.join("\n")]
188
+ end
189
+
190
+ # Sign the request with HMAC-SHA512
191
+ def sign(method, host, path, params, body = '', additional_headers = nil, options: {})
192
+ # options[:date] being passed manually is specifically for tests
193
+ date, canon = canonicalize(method, host, path, params, body, additional_headers, options: options)
194
+ [date, OpenSSL::HMAC.hexdigest('sha512', @skey, canon)]
195
+ end
196
+
197
+ # Custom Error Classes
198
+ class HeaderError < StandardError; end
199
+ class RateLimitError < StandardError; end
200
+ class ResponseCodeError < StandardError; end
201
+ class ContentTypeError < StandardError; end
202
+ class PaginationError < StandardError; end
203
+ class ChildAccountError < StandardError; end
204
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'api_client'
4
+
5
+ # Extend DuoApi class with some HTTP method helpers
6
+ class DuoApi
7
+ # Perform a GET request and parse the response as JSON
8
+ def get(path, params = {}, additional_headers = nil)
9
+ resp = request('GET', path, params, additional_headers)
10
+ raise_http_errors(resp)
11
+ raise_content_type_errors(resp[:'content-type'], 'application/json')
12
+
13
+ parse_json_to_sym_hash(resp.body)
14
+ end
15
+
16
+ # Perform a GET request and retrieve all paginated JSON data
17
+ def get_all(path, params = {}, additional_headers = nil, data_array_path: nil, metadata_path: nil)
18
+ # Set default paths for returned data array and metadata if not provided
19
+ data_array_path = if data_array_path.is_a?(Array) && (data_array_path.count >= 1)
20
+ data_array_path.map(&:to_sym)
21
+ else
22
+ [:response]
23
+ end
24
+ metadata_path = if metadata_path.is_a?(Array) && (metadata_path.count >= 1)
25
+ metadata_path.map(&:to_sym)
26
+ else
27
+ [:metadata]
28
+ end
29
+
30
+ # Ensure params keys are symbols and ignore offset parameters
31
+ params.transform_keys!(&:to_sym)
32
+ %i[offset next_offset].each do |p|
33
+ if params[p]
34
+ warn "Ignoring supplied #{p} parameter for get_all method"
35
+ params.delete(p)
36
+ end
37
+ end
38
+ # Default :limit to 1000 unless specified to minimize requests
39
+ params[:limit] ||= 1000
40
+
41
+ all_data = []
42
+ prev_results_count = 0
43
+ next_offset = 0
44
+ prev_offset = 0
45
+ resp_body_hash = {}
46
+ loop do
47
+ resp = request('GET', path, params, additional_headers)
48
+ raise_http_errors(resp)
49
+ raise_content_type_errors(resp[:'content-type'], 'application/json')
50
+
51
+ resp_body_hash = parse_json_to_sym_hash(resp.body)
52
+ resp_data_array = resp_body_hash.dig(*data_array_path)
53
+ unless resp_data_array.is_a?(Array)
54
+ raise(PaginationError,
55
+ "Object at data_array_path #{JSON.generate(data_array_path)} is not an Array")
56
+ end
57
+ all_data.concat(resp_data_array)
58
+
59
+ resp_metadata = resp_body_hash.dig(*metadata_path)
60
+ if resp_metadata.is_a?(Hash) && resp_metadata[:next_offset]
61
+ next_offset = resp_metadata[:next_offset]
62
+ next_offset = next_offset.to_i if string_int?(next_offset)
63
+
64
+ if next_offset.is_a?(Array) || next_offset.is_a?(String)
65
+ next_offset = next_offset.join(',') if next_offset.is_a?(Array)
66
+ raise(PaginationError, 'Paginated response offset error') if next_offset == prev_offset
67
+
68
+ params[:next_offset] = next_offset
69
+ else
70
+ raise(PaginationError, 'Paginated response offset error') if next_offset <= prev_offset
71
+
72
+ params[:offset] = next_offset
73
+ end
74
+ else
75
+ next_offset = nil
76
+ params.delete(:offset)
77
+ params.delete(:next_offset)
78
+ end
79
+
80
+ break if !next_offset ||
81
+ (all_data.count <= prev_results_count)
82
+
83
+ prev_results_count = all_data.count
84
+ prev_offset = next_offset
85
+ end
86
+
87
+ # Replace the data array in the last returned resp_body_hash with the all_data array
88
+ data_array_parent_hash = if data_array_path.count > 1
89
+ resp_body_hash.dig(*data_array_path[0..-2])
90
+ else
91
+ resp_body_hash
92
+ end
93
+ data_array_key = data_array_path.last
94
+ data_array_parent_hash[data_array_key] = all_data
95
+
96
+ resp_body_hash
97
+ end
98
+
99
+ # Perform a GET request to retrieve image data and return raw data
100
+ def get_image(path, params = {}, additional_headers = nil)
101
+ resp = request('GET', path, params, additional_headers)
102
+ raise_http_errors(resp)
103
+ raise_content_type_errors(resp[:'content-type'], %r{^image/})
104
+
105
+ resp.body
106
+ end
107
+
108
+ # Perform a POST request and parse the response as JSON
109
+ def post(path, params = {}, additional_headers = nil)
110
+ resp = request('POST', path, params, additional_headers)
111
+ raise_http_errors(resp)
112
+ raise_content_type_errors(resp[:'content-type'], 'application/json')
113
+
114
+ parse_json_to_sym_hash(resp.body)
115
+ end
116
+
117
+ # Perform a PUT request and parse the response as JSON
118
+ def put(path, params = {}, additional_headers = nil)
119
+ resp = request('PUT', path, params, additional_headers)
120
+ raise_http_errors(resp)
121
+ raise_content_type_errors(resp[:'content-type'], 'application/json')
122
+
123
+ parse_json_to_sym_hash(resp.body)
124
+ end
125
+
126
+ # Perform a DELETE request and parse the response as JSON
127
+ def delete(path, params = {}, additional_headers = nil)
128
+ resp = request('DELETE', path, params, additional_headers)
129
+ raise_http_errors(resp)
130
+ raise_content_type_errors(resp[:'content-type'], 'application/json')
131
+
132
+ parse_json_to_sym_hash(resp.body)
133
+ end
134
+
135
+ private
136
+
137
+ # Raise errors for non-successful HTTP responses
138
+ def raise_http_errors(resp)
139
+ return if resp.is_a?(Net::HTTPSuccess)
140
+ raise(RateLimitError, 'Rate limit retry max wait exceeded') if resp.is_a?(Net::HTTPTooManyRequests)
141
+
142
+ raise(ResponseCodeError, "HTTP #{resp.code}: #{resp.body}")
143
+ end
144
+
145
+ # Validate the content type of the response against the expected type
146
+ def raise_content_type_errors(received, allowed)
147
+ valid = false
148
+ if allowed.is_a?(Regexp)
149
+ valid = true if received =~ allowed
150
+ elsif received == allowed
151
+ valid = true
152
+ end
153
+ raise(ContentTypeError, "Invalid Content-Type #{received}, should match #{allowed.inspect}") unless valid
154
+ end
155
+
156
+ # Check if a value is a Base64 encoded string
157
+ def base64?(value)
158
+ value.is_a?(String) and Base64.strict_encode64(Base64.decode64(value)) == value
159
+ end
160
+
161
+ # Check if a string represents an integer
162
+ def string_int?(value)
163
+ value.is_a?(String) and value.to_i.to_s == value
164
+ end
165
+
166
+ # Parse JSON string to Hash with symbol keys
167
+ def parse_json_to_sym_hash(json)
168
+ JSON.parse(json, symbolize_names: true)
169
+ end
170
+
171
+ # JSON serialize Array
172
+ def json_serialized_array(value)
173
+ value.is_a?(Array) ? JSON.generate(value) : value
174
+ end
175
+
176
+ # CSV serialize Array
177
+ def csv_serialized_array(value)
178
+ value.is_a?(Array) ? value.join(',') : value
179
+ end
180
+
181
+ # Format boolean as 'true' or 'false'
182
+ def stringified_boolean(value)
183
+ %w[true 1].include?(value.to_s.downcase) ? 'true' : 'false'
184
+ end
185
+
186
+ # Format boolean as '1' or '0'
187
+ def stringified_binary_boolean(value)
188
+ %w[true 1].include?(value.to_s.downcase) ? '1' : '0'
189
+ end
190
+
191
+ # Format boolean as 'True' or 'False'
192
+ def stringified_python_boolean(value)
193
+ %w[true 1].include?(value.to_s.downcase) ? 'True' : 'False'
194
+ end
195
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'api_client'
4
+ require_relative 'api_helpers'
5
+
6
+ class DuoApi
7
+ ##
8
+ # Duo Auth API (https://duo.com/docs/authapi)
9
+ #
10
+ class Auth < DuoApi
11
+ def ping
12
+ get('/auth/v2/ping')[:response]
13
+ end
14
+
15
+ def check
16
+ get('/auth/v2/check')[:response]
17
+ end
18
+
19
+ def logo
20
+ get_image('/auth/v2/logo')
21
+ end
22
+
23
+ def enroll(**optional_params)
24
+ # optional_params: username, valid_secs
25
+ post('/auth/v2/enroll', optional_params)[:response]
26
+ end
27
+
28
+ def enroll_status(user_id:, activation_code:)
29
+ params = { user_id: user_id, activation_code: activation_code }
30
+ post('/auth/v2/enroll_status', params)[:response]
31
+ end
32
+
33
+ def preauth(**optional_params)
34
+ # optional_params: user_id, username, client_supports_verified_push, ipaddr, hostname,
35
+ # trusted_device_token
36
+ #
37
+ # Note: user_id or username must be provided
38
+ optional_params.tap do |p|
39
+ if p[:client_supports_verified_push]
40
+ p[:client_supports_verified_push] =
41
+ stringified_binary_boolean(p[:client_supports_verified_push])
42
+ end
43
+ end
44
+ post('/auth/v2/preauth', optional_params)[:response]
45
+ end
46
+
47
+ def auth(factor:, **optional_params)
48
+ # optional_params: user_id, username, ipaddr, hostname, async
49
+ #
50
+ # Note: user_id or username must be provided
51
+ optional_params.tap do |p|
52
+ p[:async] = stringified_binary_boolean(p[:async]) if p[:async]
53
+ end
54
+ params = optional_params.merge({ factor: factor })
55
+ post('/auth/v2/auth', params)[:response]
56
+ end
57
+
58
+ def auth_status(txid:)
59
+ params = { txid: txid }
60
+ get('/auth/v2/auth_status', params)[:response]
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'api_client'
4
+ require_relative 'api_helpers'
5
+
6
+ class DuoApi
7
+ ##
8
+ # Duo Device API (https://duo.com/docs/deviceapi)
9
+ #
10
+ class Device < DuoApi
11
+ attr_accessor :mkey
12
+
13
+ def initialize(ikey, skey, host, proxy = nil, mkey:, ca_file: nil, default_params: {})
14
+ super(ikey, skey, host, proxy, ca_file: ca_file, default_params: default_params)
15
+
16
+ @mkey = mkey
17
+ end
18
+
19
+ def create_device_cache(**optional_params)
20
+ # optional_params: active
21
+ optional_params.tap do |p|
22
+ p[:active] = stringified_python_boolean(p[:active]) if p[:active]
23
+ end
24
+ post("/device/v1/management_systems/#{@mkey}/device_cache", optional_params)[:response]
25
+ end
26
+
27
+ def add_device_cache_devices(cache_key:, devices:)
28
+ params = { devices: devices }
29
+ post("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}/devices", params)[:response]
30
+ end
31
+
32
+ def get_device_cache_devices(cache_key:, **optional_params)
33
+ # optional_params: device_ids
34
+ data_array_path = %i[response devices_retrieved]
35
+ metadata_path = %i[response]
36
+ get_all("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}/devices", optional_params,
37
+ data_array_path: data_array_path, metadata_path: metadata_path).dig(*data_array_path)
38
+ end
39
+
40
+ def delete_device_cache_devices(cache_key:, devices:)
41
+ params = { devices: devices }
42
+ delete("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}/devices", params)[:response]
43
+ end
44
+
45
+ def activate_device_cache(cache_key:)
46
+ post("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}/activate")[:response]
47
+ end
48
+
49
+ def delete_device_cache(cache_key:)
50
+ delete("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}")[:response]
51
+ end
52
+
53
+ def get_device_caches(status:)
54
+ params = { status: status }
55
+ get("/device/v1/management_systems/#{@mkey}/device_cache", params)[:response]
56
+ end
57
+
58
+ def get_device_cache(cache_key:)
59
+ get("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}")[:response]
60
+ end
61
+ end
62
+ end
data/lib/duo_api.rb CHANGED
@@ -1,165 +1,9 @@
1
- require 'erb'
2
- require 'json'
3
- require 'openssl'
4
- require 'net/https'
5
- require 'time'
6
- require 'uri'
1
+ # frozen_string_literal: true
7
2
 
8
- ##
9
- # A Ruby implementation of the Duo API
10
- #
11
- class DuoApi
12
- attr_accessor :ca_file
3
+ require_relative 'duo_api/api_client'
4
+ require_relative 'duo_api/api_helpers'
13
5
 
14
- if Gem.loaded_specs['duo_api']
15
- VERSION = Gem.loaded_specs['duo_api'].version
16
- else
17
- VERSION = '0.0.0'
18
- end
19
-
20
- # Constants for handling rate limit backoff
21
- MAX_BACKOFF_WAIT_SECS = 32
22
- INITIAL_BACKOFF_WAIT_SECS = 1
23
- BACKOFF_FACTOR = 2
24
- RATE_LIMITED_RESP_CODE = '429'
25
-
26
- def initialize(ikey, skey, host, proxy = nil, ca_file: nil)
27
- @ikey = ikey
28
- @skey = skey
29
- @host = host
30
- if proxy.nil?
31
- @proxy = []
32
- else
33
- proxy_uri = URI.parse proxy
34
- @proxy = [
35
- proxy_uri.host,
36
- proxy_uri.port,
37
- proxy_uri.user,
38
- proxy_uri.password
39
- ]
40
- end
41
- @ca_file = ca_file ||
42
- File.join(File.dirname(__FILE__), '..', 'ca_certs.pem')
43
- end
44
-
45
- def request(method, path, params = {}, additional_headers = nil)
46
- params_go_in_body = %w[POST PUT PATCH].include?(method)
47
- if params_go_in_body
48
- body = canon_json(params)
49
- params = {}
50
- else
51
- body = ''
52
- end
53
-
54
- uri = request_uri(path, params)
55
- current_date, signed = sign(method, uri.host, path, params, body, additional_headers)
56
-
57
- request = Net::HTTP.const_get(method.capitalize).new uri.to_s
58
- request.basic_auth(@ikey, signed)
59
- request['Date'] = current_date
60
- request['User-Agent'] = "duo_api_ruby/#{VERSION}"
61
- if params_go_in_body
62
- request['Content-Type'] = 'application/json'
63
- request.body = body
64
- end
65
-
66
- Net::HTTP.start(
67
- uri.host, uri.port, *@proxy,
68
- use_ssl: true, ca_file: @ca_file,
69
- verify_mode: OpenSSL::SSL::VERIFY_PEER
70
- ) do |http|
71
- wait_secs = INITIAL_BACKOFF_WAIT_SECS
72
- while true do
73
- resp = http.request(request)
74
- if resp.code != RATE_LIMITED_RESP_CODE or wait_secs > MAX_BACKOFF_WAIT_SECS
75
- return resp
76
- end
77
- random_offset = rand()
78
- sleep(wait_secs + random_offset)
79
- wait_secs *= BACKOFF_FACTOR
80
- end
81
- end
82
- end
83
-
84
- private
85
-
86
- def encode_key_val(k, v)
87
- # encode the key and the value for a url
88
- key = ERB::Util.url_encode(k.to_s)
89
- value = ERB::Util.url_encode(v.to_s)
90
- key + '=' + value
91
- end
92
-
93
- def canon_params(params_hash = nil)
94
- return '' if params_hash.nil?
95
- params_hash.sort.map do |k, v|
96
- # when it is an array, we want to add that as another param
97
- # eg. next_offset = ['1547486297000', '5bea1c1e-612c-4f1d-b310-75fd31385b15']
98
- if v.is_a?(Array)
99
- encode_key_val(k, v[0]) + '&' + encode_key_val(k, v[1])
100
- else
101
- encode_key_val(k, v)
102
- end
103
- end.join('&')
104
- end
105
-
106
- def canon_json(params_hash = nil)
107
- return '' if params_hash.nil?
108
- JSON.generate(Hash[params_hash.sort])
109
- end
110
-
111
- def canon_x_duo_headers(additional_headers)
112
- additional_headers ||= {}
113
-
114
- if not additional_headers.select{|k,v| k.nil? or v.nil?}.empty?
115
- raise 'Not allowed "nil" as a header name or value'
116
- end
117
-
118
- canon_list = []
119
- added_headers = []
120
- additional_headers.keys.sort.each do |header_name|
121
- header_name_lowered = header_name.downcase
122
- header_value = additional_headers[header_name]
123
- validate_additional_header(header_name_lowered, header_value, added_headers)
124
- canon_list.append(header_name_lowered, header_value)
125
- added_headers.append(header_name_lowered)
126
- end
127
-
128
- canon = canon_list.join("\x00")
129
- OpenSSL::Digest::SHA512.hexdigest(canon)
130
- end
131
-
132
- def validate_additional_header(header_name, value, added_headers)
133
- raise 'Not allowed "Null" character in header name' if header_name.include?("\x00")
134
- raise 'Not allowed "Null" character in header value' if value.include?("\x00")
135
- raise 'Additional headers must start with \'X-Duo-\'' unless header_name.downcase.start_with?('x-duo-')
136
- raise "Duplicate header passed, header=#{header_name}" if added_headers.include?(header_name.downcase)
137
- end
138
-
139
- def request_uri(path, params = nil)
140
- u = 'https://' + @host + path
141
- u += '?' + canon_params(params) unless params.nil?
142
- URI.parse(u)
143
- end
144
-
145
- def canonicalize(method, host, path, params, body = '', additional_headers = nil, options: {})
146
- # options[:date] being passed manually is specifically for tests
147
- date = options[:date] || Time.now.rfc2822()
148
- canon = [
149
- date,
150
- method.upcase,
151
- host.downcase,
152
- path,
153
- canon_params(params),
154
- OpenSSL::Digest::SHA512.hexdigest(body),
155
- canon_x_duo_headers(additional_headers)
156
- ]
157
- [date, canon.join("\n")]
158
- end
159
-
160
- def sign(method, host, path, params, body = '', additional_headers = nil, options: {})
161
- # options[:date] being passed manually is specifically for tests
162
- date, canon = canonicalize(method, host, path, params, body, additional_headers, options: options)
163
- [date, OpenSSL::HMAC.hexdigest('sha512', @skey, canon)]
164
- end
165
- end
6
+ require_relative 'duo_api/admin'
7
+ require_relative 'duo_api/accounts'
8
+ require_relative 'duo_api/auth'
9
+ require_relative 'duo_api/device'