uri_signer 0.0.1

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,123 @@
1
+ module UriSigner
2
+ # This is responsible for preparing the raw request in the format necessary for signing. API URI signatures come in a few different flavors. This one
3
+ # will take HTTP_METHOD & ESCAPED_BASE_URI & SORTED_AND_ESCAPED_QUERY_PARAMS
4
+ #
5
+ # @example
6
+ #
7
+ # request_signature = UriSigner::RequestSignature.new('get', 'https://api.example.com/core/people.json', { 'page' => 5, 'per_page' => 25 })
8
+ #
9
+ # request_signature.http_method
10
+ # # => 'GET'
11
+ #
12
+ # request_signature.base_uri
13
+ # # => 'https://api.example.com/core/people.json'
14
+ #
15
+ # request_signature.query_params
16
+ # # => { 'page' => 5, 'per_page' => 25' }
17
+ #
18
+ # request_signature.query_params?
19
+ # # => true
20
+ #
21
+ # request_signature.encoded_base_uri
22
+ # # => "https%3A%2F%2Fapi.example.com%2Fcore%2Fpeople.json"
23
+ #
24
+ # request_signature.encoded_query_params
25
+ # # => "page%3D5%26per_page%3D25"
26
+ #
27
+ # request_signature.signature
28
+ # # => "GET&https%3A%2F%2Fapi.example.com%2Fcore%2Fpeople.json&page%3D5%26per_page%3D25"
29
+ #
30
+ class RequestSignature
31
+
32
+ # The default separator used to join the http_method, encoded_base_uri, and encoded_query_params
33
+ attr_reader :separator
34
+
35
+ # Create a new RequestSignature instance
36
+ #
37
+ # @param http_method [String] The HTTP method from the request (GET, POST, PUT, or DELETE)
38
+ # @param base_uri [String] The base URI of the request. This is everything except the query string params
39
+ # @param query_params [Hash] A hash of the provided query string params
40
+ #
41
+ # It's required that you provide at least the http_method and base_uri. Params are optional
42
+ #
43
+ # @return [void]
44
+ def initialize(http_method, base_uri, query_params = {})
45
+ @http_method = http_method
46
+ @base_uri = base_uri
47
+ @query_params = query_params
48
+ @separator = '&'
49
+
50
+ raise UriSigner::Errors::MissingHttpMethodError.new("Please provide an HTTP method") unless http_method?
51
+ raise UriSigner::Errors::MissingBaseUriError.new("Please provide a Base URI") unless base_uri?
52
+ end
53
+
54
+ # Returns the full signature string
55
+ #
56
+ # @return [String]
57
+ def signature
58
+ core_signature = [self.http_method, self.encoded_base_uri]
59
+ core_signature << self.encoded_query_params if self.query_params?
60
+ core_signature.join(self.separator)
61
+ end
62
+ alias :to_s :signature
63
+
64
+ # Returns the uppercased HTTP Method
65
+ #
66
+ # @return [String]
67
+ def http_method
68
+ @http_method.upcase
69
+ end
70
+
71
+ # Returns the base URI
72
+ #
73
+ # @return [String]
74
+ def base_uri
75
+ @base_uri
76
+ end
77
+
78
+ # Returns the encoded base_uri
79
+ #
80
+ # This can be used for comparison to ensure the escaping is what you want
81
+ #
82
+ # @return [String] Escaped string of the base_uri
83
+ def encoded_base_uri
84
+ self.base_uri.extend(UriSigner::Helpers::String).escaped
85
+ end
86
+
87
+ # Returns the Query String parameters
88
+ #
89
+ # @return [Hash] The keys are stringified
90
+ def query_params
91
+ @query_params.extend(UriSigner::Helpers::Hash).stringify_keys
92
+ end
93
+
94
+ # Returns true if query params were provided
95
+ #
96
+ # @return [Bool]
97
+ def query_params?
98
+ !@query_params.blank?
99
+ end
100
+
101
+ # Returns the encoded query params as a string
102
+ #
103
+ # This joins the keys and values in one string, then joins them. Then it will escape the final contents.
104
+ #
105
+ # @return [String] Escaped string of the query params
106
+ def encoded_query_params
107
+ query_params_string.extend(UriSigner::Helpers::String).escaped
108
+ end
109
+
110
+ private
111
+ def http_method?
112
+ !@http_method.blank?
113
+ end
114
+
115
+ def base_uri?
116
+ !@base_uri.blank?
117
+ end
118
+
119
+ def query_params_string
120
+ @query_params_string ||= UriSigner::QueryHashParser.new(self.query_params).to_s
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,108 @@
1
+ module UriSigner
2
+ # This is the object that wraps the other building blocks and can be used to sign requests.
3
+ #
4
+ # @example
5
+ # http_method = "get"
6
+ # uri = "https://api.example.com/core/people.json?page=5&per_page=25&order=name:desc&select=id,name"
7
+ # secret = "my_secret"
8
+ #
9
+ # signer = UriSigner::UriSigner.new(http_method, uri, secret)
10
+ #
11
+ # signer.http_method
12
+ # # => "GET"
13
+ #
14
+ # signer.uri
15
+ # # => "https://api.example.com/core/people.json?page=5&per_page=25&order=name:desc&select=id,name"
16
+ #
17
+ # signer.signature
18
+ # # => "1AaJvChjz%2BZYJKxWsUQWNK1a%2BeGjpCs6uwQKwPw1%2FV8%3D"
19
+ #
20
+ # signer.uri_with_signature
21
+ # # => "https://api.example.com/core/people.json?_signature=6G4xiABih7FGvjwB1JsYXoeETtBCOdshIu93X1hltzk%3D"
22
+ #
23
+ # signer.valid?("1AaJvChjz%2BZYJKxWsUQWNK1a%2BeGjpCs6uwQKwPw1%2FV8%3D")
24
+ # # => true
25
+ #
26
+ # signer.valid?('1234')
27
+ # # => false
28
+ #
29
+ class Signer
30
+ # Create a new UriSigner instance
31
+ #
32
+ # @param http_method [String] The HTTP method used to make the request (GET, POST, PUT, and DELETE)
33
+ # @param uri [String] The requested URI
34
+ # @param secret [String] The secret that is used to sign the request
35
+ #
36
+ # @return [void]
37
+ def initialize(http_method, uri, secret)
38
+ @http_method = http_method
39
+ @uri = uri
40
+ @secret = secret
41
+
42
+ raise UriSigner::Errors::MissingHttpMethodError.new("Please provide an HTTP method") unless http_method?
43
+ raise UriSigner::Errors::MissingUriError.new("Please provide a URI") unless uri?
44
+ raise UriSigner::Errors::MissingSecretError.new("Please provide a secret to sign the string") unless secret?
45
+ end
46
+
47
+ # Returns the URI passed into the constructor
48
+ #
49
+ # @return [String]
50
+ def uri
51
+ @uri
52
+ end
53
+
54
+ # Returns the URI with the signature appended to the query string
55
+ #
56
+ # return [String]
57
+ def uri_with_signature
58
+ separator = if request_parser.query_params? then '&' else '?' end
59
+ "%s%s_signature=%s" % [self.uri, separator, self.signature]
60
+ end
61
+
62
+ # Returns the uppercased HTTP Method
63
+ #
64
+ # @return [String]
65
+ def http_method
66
+ @http_method.to_s.upcase
67
+ end
68
+
69
+ # Returns the signature
70
+ #
71
+ # @return [String]
72
+ def signature
73
+ uri_signature.signature
74
+ end
75
+
76
+ # Returns true if +other+ matches the proper signature
77
+ #
78
+ # @return [Bool]
79
+ def valid?(other)
80
+ self.signature === other
81
+ end
82
+
83
+ private
84
+ def uri?
85
+ !@uri.blank?
86
+ end
87
+
88
+ def http_method?
89
+ !@http_method.blank?
90
+ end
91
+
92
+ def secret?
93
+ !@secret.blank?
94
+ end
95
+
96
+ def request_parser
97
+ @request_parser ||= UriSigner::RequestParser.new(self.http_method, self.uri)
98
+ end
99
+
100
+ def request_signature
101
+ @request_signature ||= UriSigner::RequestSignature.new(request_parser.http_method, request_parser.base_uri, request_parser.query_params)
102
+ end
103
+
104
+ def uri_signature
105
+ @uri_signature ||= UriSigner::UriSignature.new(request_signature.signature, @secret)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,79 @@
1
+ module UriSigner
2
+ # This is the object that will be used to verify properly signed API URI requests
3
+ # The #secret is stored in the persistence layer for comparison. There is an API Key
4
+ # and a shared secret. All requests will be signed with the shared secret. The URI
5
+ # will also include a _signature param, where the client will sign the request and
6
+ # store it in the URI.
7
+ #
8
+ # The signing algorithm looks like this:
9
+ #
10
+ # @example
11
+ # secret = "my_secret"
12
+ # string_to_sign = "http://api.example.com/url/to_sign.json"
13
+ #
14
+ # hmac = HMAC::SHA256.new(secret)
15
+ #
16
+ # hmac.digest
17
+ # # => "??B\230????șo\271$'\256A?d?\223L\244\225\231\exR\270U"
18
+ #
19
+ # hmac << string_to_sign
20
+ #
21
+ # hmac.digest
22
+ # # => "?m?j\2761\031\235\206\260?A?\f\263\216\221\fBH?fC\215Ļ\204\233\202@/e"
23
+ #
24
+ # encoded = Base64.encode64(hmac.digest).chomp
25
+ # # => "8W3xar4xGZ2GsOJBmAyzjpEMQkg/ZkONxLuEm4JAL2U="
26
+ #
27
+ # escaped = Rack::Utils.escape(encoded)
28
+ # # => "8W3xar4xGZ2GsOJBmAyzjpEMQkg%2FZkONxLuEm4JAL2U%3D"
29
+ #
30
+ # # The final signed string is "8W3xar4xGZ2GsOJBmAyzjpEMQkg%2FZkONxLuEm4JAL2U%3D"
31
+ #
32
+ class UriSignature
33
+ # Create a new UriSignature instance
34
+ #
35
+ # @param signature_string [String] the string that needs to be signed
36
+ # @param secret [String] the secret to use for the signature
37
+ #
38
+ # @return [void]
39
+ def initialize(signature_string, secret)
40
+ @signature_string = signature_string
41
+ @secret = secret
42
+
43
+ raise UriSigner::Errors::MissingSignatureStringError.new("Please provide a string to sign") unless signature_string?
44
+ raise UriSigner::Errors::MissingSecretError.new("Please provide a secret to sign the string") unless secret?
45
+ end
46
+
47
+ # Return the signature string that was provided in the constructor
48
+ #
49
+ # @return [String]
50
+ def signature_string
51
+ @signature_string
52
+ end
53
+
54
+ # Return the signature_string after being signed with the secret
55
+ #
56
+ # @return [String]
57
+ def signature
58
+ @signature ||= sign!
59
+ end
60
+ alias :to_s :signature
61
+
62
+ private
63
+ def signature_string?
64
+ !@signature_string.blank?
65
+ end
66
+
67
+ def secret?
68
+ !@secret.blank?
69
+ end
70
+
71
+ def sign!
72
+ extension = UriSigner::Helpers::String
73
+
74
+ hmac = self.signature_string.extend(extension).hmac_signed_with(@secret)
75
+ encoded = hmac.extend(extension).base64_encoded
76
+ escaped = encoded.extend(extension).escaped
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,3 @@
1
+ module UriSigner
2
+ VERSION = "0.0.1"
3
+ end
data/reload_yard ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env sh
2
+
3
+ yardoc "lib/uri_signer/**/*.rb" && yard server
@@ -0,0 +1,79 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), '../lib/uri_signer'))
2
+
3
+ class CustomHash < Hash; end
4
+
5
+ describe UriSigner::QueryHashParser do
6
+ before do
7
+ @query_hash = { "name" => "bob", "id" => "2344" }
8
+ end
9
+
10
+ subject { described_class.new(@query_hash) }
11
+
12
+ it "converts the hash to a string" do
13
+ subject.to_s.should == "id=2344&name=bob"
14
+ end
15
+
16
+ it "raises a MissingQueryHashError if no hash is provided" do
17
+ lambda { described_class.new('') }.should raise_error(UriSigner::Errors::MissingQueryHashError)
18
+ end
19
+
20
+ it "raises a MissingQueryHashError if nil is provided" do
21
+ lambda { described_class.new(nil) }.should raise_error(UriSigner::Errors::MissingQueryHashError)
22
+ end
23
+
24
+ it "raises a MissingQueryHashError if a string is provided" do
25
+ lambda { described_class.new('name=bob') }.should raise_error(UriSigner::Errors::MissingQueryHashError)
26
+ end
27
+
28
+ it "allows a custom hash to be provided" do
29
+ hash = CustomHash.new
30
+ hash['name'] = 'bob'
31
+ lambda { described_class.new(hash) }.should_not raise_error
32
+ end
33
+
34
+ context "one param" do
35
+ before do
36
+ @query_hash = {"order"=>["name:desc", "id:desc"]}
37
+ end
38
+
39
+ subject { described_class.new(query_hash) }
40
+
41
+ it "converts a single key with multiple values to a string" do
42
+ query_hash = {"order"=>["name:desc", "id:desc"]}
43
+ parser = described_class.new(query_hash)
44
+
45
+ parser.to_s.should == "order=name:desc&order=id:desc"
46
+ end
47
+
48
+ it "converts a single key value to a string" do
49
+ query_hash = {"order"=>["name:desc"]}
50
+ parser = described_class.new(query_hash)
51
+
52
+ parser.to_s.should == "order=name:desc"
53
+ end
54
+ end
55
+
56
+ context "Parsing multiple params with different values" do
57
+ before do
58
+ @query_hash = {"order"=>["name:desc", "id:desc"], "_signature"=>"1234", "where"=>["name:nate", "id:123"]}
59
+ end
60
+
61
+ subject { described_class.new(@query_hash) }
62
+
63
+ it "converts the hash to a string" do
64
+ subject.to_s.should == "_signature=1234&order=name:desc&order=id:desc&where=name:nate&where=id:123"
65
+ end
66
+ end
67
+
68
+ context "Parsing multiple params with duplicate values" do
69
+ before do
70
+ @query_hash = {"order"=>["name:desc", "name:desc"], "_signature"=>"1234", "where"=>["name:nate", "name:nate"]}
71
+ end
72
+
73
+ subject { described_class.new(@query_hash) }
74
+
75
+ it "converts the hash to a string" do
76
+ subject.to_s.should == "_signature=1234&order=name:desc&order=name:desc&where=name:nate&where=name:nate"
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,250 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), '../lib/uri_signer'))
2
+
3
+ describe UriSigner::RequestParser do
4
+ before do
5
+ @method = 'GET'
6
+ @raw_uri = "https://api.example.com/core/people.json?_signature=1234&page=12&per_page=34&order=name:desc&select=id&select=guid"
7
+ end
8
+
9
+ subject { described_class.new(@method, @raw_uri) }
10
+
11
+ it "responds to #http_method" do
12
+ subject.should respond_to(:http_method)
13
+ end
14
+
15
+ it "responds to #https?" do
16
+ subject.should respond_to(:https?)
17
+ end
18
+
19
+ it "responds to #http?" do
20
+ subject.should respond_to(:http?)
21
+ end
22
+
23
+ it "responds to #raw_uri" do
24
+ subject.should respond_to(:raw_uri)
25
+ end
26
+
27
+ it "responds to #base_uri" do
28
+ subject.should respond_to(:base_uri)
29
+ end
30
+
31
+ it "responds to #parsed_uri" do
32
+ subject.should respond_to(:parsed_uri)
33
+ end
34
+
35
+ it "responds to #query_params" do
36
+ subject.should respond_to(:query_params)
37
+ end
38
+
39
+ it "responds to #query_params?" do
40
+ subject.should respond_to(:query_params?)
41
+ end
42
+
43
+ it "responds to #signature" do
44
+ subject.should respond_to(:signature)
45
+ end
46
+
47
+ it "responds to #signature?" do
48
+ subject.should respond_to(:signature?)
49
+ end
50
+
51
+ context "With HTML encoded URI String" do
52
+ before do
53
+ @raw_uri = "https://api.kissmetrics.dev/v1/accounts?page=2&amp;per_page=2&amp;_signature=lertaejeT%252BN3M1pCjBZo8gCRK%252BAfTmOquNvMZjmAfGw%253D"
54
+ end
55
+
56
+ subject { described_class.new(@method, @raw_uri) }
57
+
58
+ it "returns the #http_method" do
59
+ subject.http_method.should == "GET"
60
+ end
61
+
62
+ it "returns true for #https?" do
63
+ subject.https?.should be_true
64
+ end
65
+
66
+ it "returns the #base_uri" do
67
+ subject.base_uri.should == "https://api.kissmetrics.dev/v1/accounts"
68
+ end
69
+
70
+ it "returns the query values" do
71
+ subject.query_params.size.should eql(2)
72
+ end
73
+
74
+ it "does not return the signature in the query values" do
75
+ subject.query_params.should_not have_key('_signature')
76
+ end
77
+
78
+ it "returns true for #query_params?" do
79
+ subject.query_params?.should be_true
80
+ end
81
+
82
+ it "includes the proper keys in the query value" do
83
+ subject.query_params.should include('page', 'per_page')
84
+ end
85
+
86
+ it "returns the signature from the url" do
87
+ subject.signature.should == "lertaejeT%2BN3M1pCjBZo8gCRK%2BAfTmOquNvMZjmAfGw%3D"
88
+ end
89
+
90
+ it "returns true for #signature?" do
91
+ subject.signature?.should be_true
92
+ end
93
+ end
94
+
95
+ context "With valid values" do
96
+ it "returns the #http_method" do
97
+ subject.http_method.should == "GET"
98
+ end
99
+
100
+ it "returns true for #https?" do
101
+ subject.https?.should be_true
102
+ end
103
+
104
+ it "returns false for #http?" do
105
+ subject.http?.should be_false
106
+ end
107
+
108
+ it "returns the #base_uri" do
109
+ subject.base_uri.should == "https://api.example.com/core/people.json"
110
+ end
111
+
112
+ it "returns the #raw_uri" do
113
+ subject.raw_uri.should == "https://api.example.com/core/people.json?_signature=1234&page=12&per_page=34&order=name:desc&select=id&select=guid"
114
+ end
115
+
116
+ it "returns the query values" do
117
+ subject.query_params.size.should eql(4)
118
+ end
119
+
120
+ it "does not return the signature in the query values" do
121
+ subject.query_params.should_not have_key('_signature')
122
+ end
123
+
124
+ it "returns true for #query_params?" do
125
+ subject.query_params?.should be_true
126
+ end
127
+
128
+ it "includes the proper keys in the query value" do
129
+ subject.query_params.should include('page','per_page','order')
130
+ end
131
+
132
+ it "returns multiple values for query param key" do
133
+ subject.query_params['select'].should eql ['id', 'guid']
134
+ end
135
+
136
+ it "returns the parsed_uri as an Addressable::URI object" do
137
+ subject.parsed_uri.should be_a_kind_of(Addressable::URI)
138
+ end
139
+
140
+ it "returns the signature from the url" do
141
+ subject.signature.should == "1234"
142
+ end
143
+
144
+ it "returns true for #signature?" do
145
+ subject.signature?.should be_true
146
+ end
147
+ end
148
+
149
+ context "without query params" do
150
+ before do
151
+ @raw_uri = "https://api.example.com/core/people.json"
152
+ end
153
+
154
+ subject { described_class.new(@method, @raw_uri) }
155
+
156
+ it "returns empty query values" do
157
+ subject.query_params.size.should eql(0)
158
+ end
159
+
160
+ it "returns false for #signature?" do
161
+ subject.signature?.should be_false
162
+ end
163
+
164
+ it "returns false for #query_params?" do
165
+ subject.query_params?.should be_false
166
+ end
167
+ end
168
+
169
+ context "Determining the scheme" do
170
+ before do
171
+ @raw_uri = "http://api.example.com"
172
+ end
173
+
174
+ subject { described_class.new(@method, @raw_uri) }
175
+
176
+ it "returns false for #https?" do
177
+ subject.https?.should be_false
178
+ end
179
+
180
+ it "returns true for #http?" do
181
+ subject.http?.should be_true
182
+ end
183
+ end
184
+
185
+ context "With a signature and other query string values in the URI" do
186
+ before do
187
+ @raw_uri = "https://api.example.com/test?page=3&per_page=23&order=name:desc&_signature=3434343434gh="
188
+ end
189
+
190
+ subject { described_class.new(@method, @raw_uri) }
191
+
192
+ it "returns true for #signature?" do
193
+ subject.signature?.should be_true
194
+ end
195
+
196
+ it "escapes the signature" do
197
+ subject.signature.should == "3434343434gh%3D"
198
+ end
199
+ end
200
+
201
+ context "With a signature and no other query string values in the URI" do
202
+ before do
203
+ @raw_uri = "https://api.example.com/test?_signature=3434343434gh="
204
+ end
205
+
206
+ subject { described_class.new(@method, @raw_uri) }
207
+
208
+ it "returns true for #signature?" do
209
+ subject.signature?.should be_true
210
+ end
211
+
212
+ it "escapes the signature" do
213
+ subject.signature.should == "3434343434gh%3D"
214
+ end
215
+
216
+ it "returns empty query values" do
217
+ subject.query_params.size.should eql(0)
218
+ end
219
+
220
+ it "returns false for #query_params?" do
221
+ subject.query_params?.should be_false
222
+ end
223
+ end
224
+
225
+ context "Without a signature in the URI" do
226
+ before do
227
+ @raw_uri = "https://api.example.com"
228
+ end
229
+
230
+ subject { described_class.new(@method, @raw_uri) }
231
+
232
+ it "returns an empty string for #signature" do
233
+ subject.signature.should be_empty
234
+ end
235
+
236
+ it "returns false for #signature?" do
237
+ subject.signature?.should be_false
238
+ end
239
+ end
240
+
241
+ context "Validations" do
242
+ it "raises a UriSigner:MissingHttpMethodError if an empty string is provided" do
243
+ lambda { described_class.new('', @raw_uri)}.should raise_error(UriSigner::Errors::MissingHttpMethodError)
244
+ end
245
+
246
+ it "raises a UriSigner::MissingHttpMethodError if nil is provided" do
247
+ lambda { described_class.new(nil, @raw_uri)}.should raise_error(UriSigner::Errors::MissingHttpMethodError)
248
+ end
249
+ end
250
+ end