Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update for sig version 5 and JSON request bodies #40

Merged
merged 1 commit into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion duo_api.gemspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Gem::Specification.new do |s|
s.name = 'duo_api'
s.version = '1.3.0'
s.version = '1.4.0'
s.summary = 'Duo API Ruby'
s.description = 'A Ruby implementation of the Duo API.'
s.email = '[email protected]'
Expand All @@ -15,4 +15,5 @@ Gem::Specification.new do |s|
s.add_development_dependency 'rubocop', '~> 0.49.0'
s.add_development_dependency 'test-unit', '~> 3.2'
s.add_development_dependency 'mocha', '~> 1.8.0'
s.add_development_dependency 'ostruct', '~> 0.1.0'
end
92 changes: 73 additions & 19 deletions lib/duo_api.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'erb'
require 'json'
require 'openssl'
require 'net/https'
require 'time'
Expand All @@ -10,6 +11,12 @@
class DuoApi
attr_accessor :ca_file

if Gem.loaded_specs['duo_api']
VERSION = Gem.loaded_specs['duo_api'].version
else
VERSION = '0.0.0'
end

# Constants for handling rate limit backoff
MAX_BACKOFF_WAIT_SECS = 32
INITIAL_BACKOFF_WAIT_SECS = 1
Expand All @@ -32,26 +39,40 @@ def initialize(ikey, skey, host, proxy = nil, ca_file: nil)
]
end
@ca_file = ca_file ||
File.join(File.dirname(__FILE__), '..', 'ca_certs.pem')
File.join(File.dirname(__FILE__), '..', 'ca_certs.pem')
end

def request(method, path, params = nil)
def request(method, path, params = {}, additional_headers = nil)
params_go_in_body = %w[POST PUT PATCH].include?(method)
if params_go_in_body
body = canon_json(params)
params = {}
else
body = ''
end

uri = request_uri(path, params)
current_date, signed = sign(method, uri.host, path, params)
current_date, signed = sign(method, uri.host, path, params, body, additional_headers)

request = Net::HTTP.const_get(method.capitalize).new uri.to_s
request.basic_auth(@ikey, signed)
request['Date'] = current_date
request['User-Agent'] = 'duo_api_ruby/1.3.0'
request['User-Agent'] = "duo_api_ruby/#{VERSION}"
if params_go_in_body
request['Content-Type'] = 'application/json'
request.body = body
end

Net::HTTP.start(uri.host, uri.port, *@proxy,
use_ssl: true, ca_file: @ca_file,
verify_mode: OpenSSL::SSL::VERIFY_PEER) do |http|
Net::HTTP.start(
uri.host, uri.port, *@proxy,
use_ssl: true, ca_file: @ca_file,
verify_mode: OpenSSL::SSL::VERIFY_PEER
) do |http|
wait_secs = INITIAL_BACKOFF_WAIT_SECS
while true do
resp = http.request(request)
if resp.code != RATE_LIMITED_RESP_CODE or wait_secs > MAX_BACKOFF_WAIT_SECS
return resp
return resp
end
random_offset = rand()
sleep(wait_secs + random_offset)
Expand All @@ -69,7 +90,7 @@ def encode_key_val(k, v)
key + '=' + value
end

def encode_params(params_hash = nil)
def canon_params(params_hash = nil)
return '' if params_hash.nil?
params_hash.sort.map do |k, v|
# when it is an array, we want to add that as another param
Expand All @@ -82,30 +103,63 @@ def encode_params(params_hash = nil)
end.join('&')
end

def time
Time.now.rfc2822
def canon_json(params_hash = nil)
return '' if params_hash.nil?
JSON.generate(Hash[params_hash.sort])
end

def canon_x_duo_headers(additional_headers)
additional_headers ||= {}

if not additional_headers.select{|k,v| k.nil? or v.nil?}.empty?
raise 'Not allowed "nil" as a header name or value'
end

canon_list = []
added_headers = []
additional_headers.keys.sort.each do |header_name|
header_name_lowered = header_name.downcase
header_value = additional_headers[header_name]
validate_additional_header(header_name_lowered, header_value, added_headers)
canon_list.append(header_name_lowered, header_value)
added_headers.append(header_name_lowered)
end

canon = canon_list.join("\x00")
OpenSSL::Digest::SHA512.hexdigest(canon)
end

def validate_additional_header(header_name, value, added_headers)
raise 'Not allowed "Null" character in header name' if header_name.include?("\x00")
raise 'Not allowed "Null" character in header value' if value.include?("\x00")
raise 'Additional headers must start with \'X-Duo-\'' unless header_name.downcase.start_with?('x-duo-')
raise "Duplicate header passed, header=#{header_name}" if added_headers.include?(header_name.downcase)
end

def request_uri(path, params = nil)
u = 'https://' + @host + path
u += '?' + encode_params(params) unless params.nil?
u += '?' + canon_params(params) unless params.nil?
URI.parse(u)
end

def canonicalize(method, host, path, params, options = {})
options[:date] ||= time
def canonicalize(method, host, path, params, body = '', additional_headers = nil, options: {})
# options[:date] being passed manually is specifically for tests
date = options[:date] || Time.now.rfc2822()
canon = [
options[:date],
date,
method.upcase,
host.downcase,
path,
encode_params(params)
canon_params(params),
OpenSSL::Digest::SHA512.hexdigest(body),
canon_x_duo_headers(additional_headers)
]
[options[:date], canon.join("\n")]
[date, canon.join("\n")]
end

def sign(method, host, path, params, options = {})
date, canon = canonicalize(method, host, path, params, date: options[:date])
def sign(method, host, path, params, body = '', additional_headers = nil, options: {})
# options[:date] being passed manually is specifically for tests
date, canon = canonicalize(method, host, path, params, body, additional_headers, options: options)
[date, OpenSSL::HMAC.hexdigest('sha512', @skey, canon)]
end
end
89 changes: 55 additions & 34 deletions test/test_duo_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def test_default_ca_file_exists
class TestQueryParameters < TestCase

def assert_canon_params(params, expected)
actual = @client.send(:encode_params, params)
actual = @client.send(:canon_params, params)
assert_equal(expected, actual)
end

Expand Down Expand Up @@ -108,21 +108,40 @@ def test_encode_key_val
end

class TestCanonicalize < TestCase
def test_v2
def test_sig_v5_params
params = {
"\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\u00c8\uc170" => "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0",
"\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813" => "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30",
"\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042" => "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3",
"\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934" => "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU"
}
body = ''
additional_headers = nil
expected_date = 'Fri, 07 Dec 2012 17:18:00 -0000'
expected_canon = "Fri, 07 Dec 2012 17:18:00 -0000\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU"
expected_canon = expected_date + "\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU\ncf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e\ncf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"
actual_date, actual_canon = @client.send(:canonicalize,
'PoSt', HOST, '/Foo/BaR2/qux', params, :date => expected_date)
'PoSt', HOST, '/Foo/BaR2/qux', params, body, additional_headers, options: {:date => expected_date})
assert_equal(expected_canon, actual_canon)
assert_equal(expected_date, actual_date)
end

def test_sig_v5_json
params_hash = {
"\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\u00c8\uc170" => "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0",
"\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813" => "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30",
"\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042" => "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3",
"\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934" => "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU"
}
params = {}
body = JSON.generate(Hash[params_hash.sort])
additional_headers = nil
expected_date = 'Fri, 07 Dec 2012 17:18:00 -0000'
expected_canon = expected_date + "\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n\n069842dc1b1158ce098fb8cbabf4695fe5b6dbbe0189293c45253b80522d6c56aaed43cfeeb541222d5a34d56f57e2b420b70856d1f09ba346418e7a5bca6397\ncf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"
actual_date, actual_canon = @client.send(:canonicalize,
'PoSt', HOST, '/Foo/BaR2/qux', params, body, additional_headers, options: {:date => expected_date})
assert_equal(expected_canon, actual_canon)
assert_equal(expected_date, actual_date)
end
end

class TestSign < TestCase
Expand All @@ -133,60 +152,62 @@ def test_hmac_sha512
"\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042" => "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3",
"\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934" => "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU"
}
body = ''
additional_headers = nil
expected_date = 'Fri, 07 Dec 2012 17:18:00 -0000'
expected_sig = '0508065035a03b2a1de2f453e629e791d180329e157f65df6b3e0f08299d4321e1c5c7a7c7ee6b9e5fc80d1fb6fbf3ad5eb7c44dd3b3985a02c37aca53ec3698'
expected_sig = 'de886475f5ee8cf32872a7c10869e4dce7a0038f8b0da01d903469c6240473dfd1abf98b40b34b9ad7fbc99d5df3f2279e7105fd9101c428b94faaeec5e179cf'
actual_date, actual_sig = @client.send(:sign,
'PoSt', HOST, '/Foo/BaR2/qux', params, :date => expected_date)
'PoSt', HOST, '/Foo/BaR2/qux', params, body, additional_headers, options: {:date => expected_date})
assert_equal(expected_sig, actual_sig)
assert_equal(expected_date, actual_date)
end

end

class MockResponse < Object
attr_reader :code
attr_reader :code

def initialize(code)
@code = code
end
def initialize(code)
@code = code
end
end

class TestRetryRequests < TestCase
def setup
super
@mock_http = mock()
Net::HTTP.expects(:start).yields(@mock_http)
super
@mock_http = mock()
Net::HTTP.expects(:start).yields(@mock_http)

@limited_response = MockResponse.new('429')
@ok_response = MockResponse.new('200')
@limited_response = MockResponse.new('429')
@ok_response = MockResponse.new('200')
end

def test_non_limited_response
@mock_http.expects(:request).returns(@ok_response)
@client.expects(:sleep).never
actual_response = @client.request('GET', '/foo/bar')
assert_equal(@ok_response, actual_response)
@mock_http.expects(:request).returns(@ok_response)
@client.expects(:sleep).never
actual_response = @client.request('GET', '/foo/bar')
assert_equal(@ok_response, actual_response)
end

def test_single_limited_response
@mock_http.expects(:request).twice.returns(@limited_response, @ok_response)
@client.expects(:rand).returns(0.123)
@client.expects(:sleep).with(1.123)
actual_response = @client.request('GET', '/foo/bar')
assert_equal(@ok_response, actual_response)
@mock_http.expects(:request).twice.returns(@limited_response, @ok_response)
@client.expects(:rand).returns(0.123)
@client.expects(:sleep).with(1.123)
actual_response = @client.request('GET', '/foo/bar')
assert_equal(@ok_response, actual_response)
end

def test_all_limited_responses
@mock_http.expects(:request).times(7).returns(@limited_response)
@client.expects(:rand).times(6).returns(0.123)
@client.expects(:sleep).with(1.123)
@client.expects(:sleep).with(2.123)
@client.expects(:sleep).with(4.123)
@client.expects(:sleep).with(8.123)
@client.expects(:sleep).with(16.123)
@client.expects(:sleep).with(32.123)
actual_response = @client.request('GET', '/foo/bar')
assert_equal(@limited_response, actual_response)
@mock_http.expects(:request).times(7).returns(@limited_response)
@client.expects(:rand).times(6).returns(0.123)
@client.expects(:sleep).with(1.123)
@client.expects(:sleep).with(2.123)
@client.expects(:sleep).with(4.123)
@client.expects(:sleep).with(8.123)
@client.expects(:sleep).with(16.123)
@client.expects(:sleep).with(32.123)
actual_response = @client.request('GET', '/foo/bar')
assert_equal(@limited_response, actual_response)
end

end