simple_oauth 0.4.1 → 0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73fa4671190dbbe2dc7178055d4ef82aeaabf7044b384d198ee12a0fa48f6de5
4
- data.tar.gz: f550391eb54731d6593dcab35f19bc419cbcbe63a6e48036be5f3cf4a419d069
3
+ metadata.gz: 7eca3b8d3940706967367e25b8f42261d48b325c8f2cd1c1cc01ad58a800f284
4
+ data.tar.gz: 63cec8ec4204dd64c40fdf61ecf1782df0364687794896199ded8a6dcb890ca3
5
5
  SHA512:
6
- metadata.gz: 58f2b94d0568c491e46450e0c5cd35a9e45bed5827ee854fe17e02f4d02d74c3f5dd300eeebbfc890964afb72000a6e0f3b6db38db6599f063d3385b9dbe54c3
7
- data.tar.gz: be077693f5d1c95b30f748f1915376862b540c419558dc6376d39c99c5991e1380f172c79e37a396f9e1450af6f22e48cec1c843b2c3afce3488731ba69401fe
6
+ metadata.gz: 967d4c8e1f174da75c8818fa0b94432ca2878d44474e5433930aa5bca728d4636b1ae4594e930be98fc68d6d9d57a40622dd5075ff507242100f424b95213e75
7
+ data.tar.gz: 7b24159117153d934834c2767692be078a78c58ec6fc34775d91bd90794767bbf86cbde9fb6306bcf5640b2a7b212f8bd0866e980ed3a871a3e0212120a7494b
data/CHANGELOG.md CHANGED
@@ -1,3 +1,33 @@
1
+ ## [0.5.0] - 2026-09-12
2
+
3
+ ### Added
4
+
5
+ * `Header.from_request`, which builds a header for a request object such as a `Net::HTTPRequest`, signing its query parameters, its form-encoded body, or hashing any other body
6
+ * `Header.parse_query`, for OAuth credentials sent in a query string
7
+ * `Signature.digest`, and a `digest:` option on `Signature.register`, which gives the hash algorithm a signature method signs with
8
+ * `Signature.verify` and a `verify:` option on `Signature.register`, for signature methods that cannot be verified by recomputing the signature
9
+ * `Signature.decode_base64`
10
+
11
+ ### Fixed
12
+
13
+ * Check `oauth_body_hash` against the body a header was built with when verifying, so a body changed after signing no longer verifies against the hash its signature covers
14
+ * Verify signatures without merging the given secrets into the header's own options, where anything else reading the header could see them
15
+ * Compare signatures in constant time when verifying
16
+ * Compute `oauth_body_hash` with the hash algorithm of the signature method, such as SHA-256 for HMAC-SHA256; it was always SHA-1
17
+ * Sign a parameter whose value is an Array as one parameter per value, as a repeated parameter; the Array was previously signed as its Ruby representation
18
+ * Verify RSA signatures with the signer's public key, which is all a verifier has; `Header#valid?` previously recomputed the signature and so needed the private key
19
+ * Match the form-encoded media type exactly when signing a body, rather than by prefix, so a media type such as `application/x-www-form-urlencoded-json` is hashed instead of signed as parameters, and `Application/X-WWW-Form-Urlencoded` is recognized
20
+
21
+ ## [0.4.2] - 2026-09-12
22
+
23
+ ### Added
24
+
25
+ * Document passing `Header` parameters as an Array of key-value pairs when a key repeats
26
+
27
+ ### Fixed
28
+
29
+ * Accept an Array of key-value pairs as `Header` parameters in the RBS signatures, which already worked at runtime
30
+
1
31
  ## [0.4.1] - 2026-04-20
2
32
 
3
33
  ### Fixed
data/README.md CHANGED
@@ -40,6 +40,35 @@ header.to_s
40
40
  # => "OAuth oauth_consumer_key=\"consumer_key\", oauth_nonce=\"...\", ..."
41
41
  ```
42
42
 
43
+ ### Signing a Request
44
+
45
+ `Header.from_request` takes the method, URL, and parameters from a request object, such as a `Net::HTTPRequest`. Query parameters are always signed, a form-encoded body is signed as parameters, and any other body is hashed into `oauth_body_hash`:
46
+
47
+ ```ruby
48
+ request = Net::HTTP::Post.new(URI("https://api.example.com/statuses"))
49
+ request.set_form_data(status: "Hello")
50
+ request["Authorization"] = SimpleOAuth::Header.from_request(request,
51
+ consumer_key: "key",
52
+ consumer_secret: "secret"
53
+ ).to_s
54
+ ```
55
+
56
+ ### Repeated Parameters
57
+
58
+ Pass an Array of values, or an Array of key-value pairs, when a key repeats:
59
+
60
+ ```ruby
61
+ header = SimpleOAuth::Header.new(:post, url, {"ids" => %w[1 2]},
62
+ consumer_key: "key",
63
+ consumer_secret: "secret"
64
+ )
65
+
66
+ header = SimpleOAuth::Header.new(:post, url, [["ids", "1"], ["ids", "2"]],
67
+ consumer_key: "key",
68
+ consumer_secret: "secret"
69
+ )
70
+ ```
71
+
43
72
  ### Signature Methods
44
73
 
45
74
  Built-in signature methods: `HMAC-SHA1` (default), `HMAC-SHA256`, `RSA-SHA1`, `RSA-SHA256`, and `PLAINTEXT`.
@@ -76,7 +105,7 @@ SimpleOAuth::Signature.methods # => ["hmac_sha1", "hmac_sha256", "rsa_sha1", "rs
76
105
 
77
106
  ### OAuth Request Body Hash
78
107
 
79
- For non-form-encoded request bodies (e.g., JSON), pass the body as the fifth parameter to compute `oauth_body_hash`:
108
+ For non-form-encoded request bodies (e.g., JSON), pass the body as the fifth parameter to compute `oauth_body_hash`, which is hashed with the signature method's algorithm. Form-encoded bodies are signed by passing their parameters as `params` instead.
80
109
 
81
110
  ```ruby
82
111
  json_body = '{"text": "Hello, World!"}'
@@ -109,11 +138,14 @@ parsed = SimpleOAuth::Header.parse('OAuth oauth_consumer_key="key", oauth_signat
109
138
  # => {consumer_key: "key", signature: "sig"}
110
139
  ```
111
140
 
112
- Parse OAuth credentials from a form-encoded POST body:
141
+ Parse OAuth credentials from a form-encoded POST body, or from a query string:
113
142
 
114
143
  ```ruby
115
144
  parsed = SimpleOAuth::Header.parse_form_body('oauth_consumer_key=key&oauth_signature=sig&status=hello')
116
145
  # => {consumer_key: "key", signature: "sig"}
146
+
147
+ parsed = SimpleOAuth::Header.parse_query("oauth_consumer_key=key&status=hello")
148
+ # => {consumer_key: "key"}
117
149
  ```
118
150
 
119
151
  ### Verifying Signatures
@@ -127,6 +159,25 @@ header.valid?(consumer_secret: "secret", token_secret: "token_secret")
127
159
  # => true
128
160
  ```
129
161
 
162
+ Verifying compares signatures in constant time and leaves the header's own options untouched, so the secrets stay with the caller.
163
+
164
+ RSA signatures verify with the client's public key, which is all a server has:
165
+
166
+ ```ruby
167
+ header.valid?(consumer_secret: File.read("client_public_key.pem"))
168
+ ```
169
+
170
+ Custom signature methods that cannot be verified by recomputing the signature register a `verify` block:
171
+
172
+ ```ruby
173
+ SimpleOAuth::Signature.register("RSA-SHA512", rsa: true,
174
+ verify: ->(key, signature_base, signature) {
175
+ OpenSSL::PKey::RSA.new(key).verify("SHA512", SimpleOAuth::Signature.decode_base64(signature), signature_base)
176
+ }) do |private_key_pem, signature_base|
177
+ SimpleOAuth::Signature.encode_base64(OpenSSL::PKey::RSA.new(private_key_pem).sign("SHA512", signature_base))
178
+ end
179
+ ```
180
+
130
181
  ## Contributing
131
182
 
132
183
  Bug reports and pull requests are welcome on GitHub at https://github.com/laserlemon/simple_oauth.
@@ -13,17 +13,18 @@ module SimpleOAuth
13
13
  #
14
14
  # @api public
15
15
  # @param body [String, nil] optional request body for computing oauth_body_hash
16
+ # @param signature_method [String] the signature method, whose hash algorithm oauth_body_hash uses
16
17
  # @return [Hash] default options including nonce, signature_method, timestamp, and version
17
18
  # @example
18
19
  # SimpleOAuth::Header.default_options
19
20
  # # => {nonce: "abc123...", signature_method: "HMAC-SHA1", timestamp: "1234567890", version: "1.0"}
20
- def default_options(body = nil)
21
+ def default_options(body = nil, signature_method = DEFAULT_SIGNATURE_METHOD)
21
22
  {
22
23
  nonce: generate_nonce,
23
- signature_method: DEFAULT_SIGNATURE_METHOD,
24
+ signature_method: signature_method,
24
25
  timestamp: Integer(Time.now).to_s,
25
26
  version: OAUTH_VERSION
26
- }.tap { |opts| opts[:body_hash] = body_hash(body) if body }
27
+ }.tap { |opts| opts[:body_hash] = body_hash(body, Signature.digest(signature_method)) if body }
27
28
  end
28
29
 
29
30
  # Computes the oauth_body_hash for a request body
@@ -52,6 +53,30 @@ module SimpleOAuth
52
53
  Parser.new(header).parse(PARSE_KEYS)
53
54
  end
54
55
 
56
+ # Builds a header for an HTTP request, signing the parameters it carries
57
+ #
58
+ # The request's query parameters are always signed. A form-encoded body is signed as
59
+ # parameters, and any other body is hashed into oauth_body_hash.
60
+ #
61
+ # @api public
62
+ # @param request [#method, #uri, #body] the request to sign, such as a Net::HTTPRequest
63
+ # @param oauth [Hash, String] OAuth options hash or an existing Authorization header to parse
64
+ # @return [Header] the header for the request
65
+ # @raise [ArgumentError] if the request has no URI
66
+ # @example
67
+ # request = Net::HTTP::Post.new(URI("https://api.example.com/statuses"))
68
+ # request.set_form_data(status: "Hello")
69
+ # request["Authorization"] = SimpleOAuth::Header.from_request(request,
70
+ # consumer_key: "key", consumer_secret: "secret").to_s
71
+ def from_request(request, oauth = {})
72
+ uri = request.uri || raise(ArgumentError, "The request has no URI")
73
+ body = request.body
74
+ return new(request.method, uri, CGI.parse(body.to_s), oauth) if form_encoded?(request)
75
+
76
+ no_params = {} #: Header::request_params
77
+ new(request.method, uri, no_params, oauth, body)
78
+ end
79
+
55
80
  # Parses OAuth parameters from a form-encoded POST body
56
81
  #
57
82
  # OAuth 1.0 allows credentials to be transmitted in the request body for
@@ -63,6 +88,9 @@ module SimpleOAuth
63
88
  # @example
64
89
  # SimpleOAuth::Header.parse_form_body('oauth_consumer_key=key&oauth_signature=sig&status=hello')
65
90
  # # => {consumer_key: "key", signature: "sig"}
91
+ # @example Parse the credentials from a query string
92
+ # SimpleOAuth::Header.parse_query('oauth_consumer_key=key&status=hello')
93
+ # # => {consumer_key: "key"}
66
94
  def parse_form_body(body)
67
95
  valid_keys = PARSE_KEYS.map(&:to_s)
68
96
 
@@ -76,8 +104,40 @@ module SimpleOAuth
76
104
  result
77
105
  end
78
106
 
107
+ # @!method parse_query(query)
108
+ # Parses OAuth parameters from a query string, which RFC 5849 Section 3.5.3 also allows
109
+ #
110
+ # @api public
111
+ # @param query [String, #to_s] the query string
112
+ # @return [Hash] parsed OAuth attributes with symbol keys (only valid OAuth keys)
113
+ # @example
114
+ # SimpleOAuth::Header.parse_query("oauth_consumer_key=key&status=hello")
115
+ # # => {consumer_key: "key"}
116
+ alias_method :parse_query, :parse_form_body
117
+
79
118
  private
80
119
 
120
+ # Checks whether a request carries a form-encoded body
121
+ #
122
+ # @api private
123
+ # @param request [#[]] the request
124
+ # @return [Boolean] true if the body is form-encoded
125
+ def form_encoded?(request)
126
+ media_type(request).eql?(FORM_CONTENT_TYPE)
127
+ end
128
+
129
+ # Extracts the media type from a request, without its parameters
130
+ #
131
+ # Per RFC 9110 Section 8.3 the media type is case-insensitive and may carry parameters,
132
+ # such as a charset, that play no part in identifying it.
133
+ #
134
+ # @api private
135
+ # @param request [#[]] the request
136
+ # @return [String] the lowercase media type, or an empty String when the request declares none
137
+ def media_type(request)
138
+ request["Content-Type"].to_s.split(";").first.to_s.strip.downcase
139
+ end
140
+
81
141
  # Generates a random nonce for OAuth requests
82
142
  #
83
143
  # @api private
@@ -0,0 +1,75 @@
1
+ require "cgi"
2
+
3
+ module SimpleOAuth
4
+ class Header
5
+ # Normalization of the request parameters that are signed, per RFC 5849 Section 3.4.1.3
6
+ #
7
+ # @api private
8
+ module Params
9
+ private
10
+
11
+ # Extracts valid OAuth attributes from options
12
+ #
13
+ # @api private
14
+ # @return [Hash] OAuth attributes without signature or realm
15
+ def attributes
16
+ validate_option_keys!
17
+ options.slice(*ATTRIBUTE_KEYS).transform_keys { |key| :"#{OAUTH_PREFIX}#{key}" }
18
+ end
19
+
20
+ # Validates that no unknown keys are present in options
21
+ #
22
+ # @api private
23
+ # @raise [InvalidOptionsError] if extra keys are found
24
+ # @return [void]
25
+ def validate_option_keys!
26
+ return if options[:ignore_extra_keys]
27
+
28
+ extra_keys = options.keys - ATTRIBUTE_KEYS - IGNORED_KEYS
29
+ return if extra_keys.empty?
30
+
31
+ raise InvalidOptionsError, "Unknown option keys: #{extra_keys.map(&:inspect).join(", ")}"
32
+ end
33
+
34
+ # Extracts query parameters from the request URL
35
+ #
36
+ # @api private
37
+ # @return [Array<Array>] URL query parameters as key-value pairs
38
+ def url_params
39
+ CGI.parse(@uri.query || "").flat_map do |key, values|
40
+ values.sort.map { |value| [key, value] }
41
+ end
42
+ end
43
+
44
+ # Normalizes and sorts all request parameters for signing
45
+ #
46
+ # @api private
47
+ # @return [String] normalized request parameters
48
+ def normalized_params
49
+ signature_params
50
+ .map { |key, value| [Header.escape(key), Header.escape(value)] }
51
+ .sort
52
+ .map { |pair| pair.join("=") }
53
+ .join("&")
54
+ end
55
+
56
+ # Collects all parameters to include in signature
57
+ #
58
+ # @api private
59
+ # @return [Array<Array>] all parameters for signature as key-value pairs
60
+ def signature_params
61
+ attributes.to_a + expanded_params + url_params
62
+ end
63
+
64
+ # Expands parameters into one pair per value, for parameters with several values
65
+ #
66
+ # @api private
67
+ # @return [Array<Array(Object, Object)>] the parameter pairs
68
+ def expanded_params
69
+ params.flat_map do |key, value|
70
+ value.is_a?(Array) ? value.map { |element| [key, element] } : [[key, value]]
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -5,6 +5,7 @@ require_relative "errors"
5
5
  require_relative "parser"
6
6
  require_relative "signature"
7
7
  require_relative "header/class_methods"
8
+ require_relative "header/params"
8
9
 
9
10
  module SimpleOAuth
10
11
  # Generates OAuth 1.0 Authorization headers for HTTP requests
@@ -17,6 +18,8 @@ module SimpleOAuth
17
18
  # Prefix for OAuth parameters
18
19
  OAUTH_PREFIX = "oauth_".freeze
19
20
 
21
+ # The content type whose body parameters are signed, per RFC 5849 Section 3.4.1.3.1
22
+ FORM_CONTENT_TYPE = "application/x-www-form-urlencoded".freeze
20
23
  # Default signature method per RFC 5849
21
24
  DEFAULT_SIGNATURE_METHOD = "HMAC-SHA1".freeze
22
25
 
@@ -41,7 +44,7 @@ module SimpleOAuth
41
44
 
42
45
  # The request parameters to be signed
43
46
  #
44
- # @return [Hash] the request parameters
47
+ # @return [Hash, Array<Array(String, Object)>] the request parameters
45
48
  # @example
46
49
  # header.params # => {"status" => "Hello"}
47
50
  attr_reader :params
@@ -60,6 +63,8 @@ module SimpleOAuth
60
63
  # header.options # => {consumer_key: "key", nonce: "..."}
61
64
  attr_reader :options
62
65
 
66
+ include Params
67
+
63
68
  extend ClassMethods
64
69
  extend Encoding
65
70
 
@@ -68,7 +73,8 @@ module SimpleOAuth
68
73
  # @api public
69
74
  # @param method [String, Symbol] the HTTP method
70
75
  # @param url [String, URI] the request URL
71
- # @param params [Hash] the request parameters (for form-encoded bodies)
76
+ # @param params [Hash, Array<Array(String, Object)>] the request parameters (for form-encoded bodies),
77
+ # as a Hash or as an Array of key-value pairs when a key repeats
72
78
  # @param oauth [Hash, String] OAuth options hash or an existing Authorization header to parse
73
79
  # @param body [String, nil] raw request body for oauth_body_hash (for non-form-encoded bodies)
74
80
  # @example Create a header with OAuth options
@@ -117,16 +123,15 @@ module SimpleOAuth
117
123
  # @api public
118
124
  # @param secrets [Hash] the consumer_secret and token_secret for validation
119
125
  # @return [Boolean] true if the signature is valid, false otherwise
126
+ # @note When the header was built with a body, the signed oauth_body_hash must match that body,
127
+ # so a tampered body fails verification even though its signature covers the claimed hash
120
128
  # @example
121
129
  # parsed_header = SimpleOAuth::Header.new(:get, url, {}, authorization_header)
122
130
  # parsed_header.valid?(consumer_secret: "secret", token_secret: "token_secret")
123
131
  # # => true
124
132
  def valid?(secrets = {})
125
- original_options = options.dup #: Hash[Symbol, untyped]
126
- options.merge!(secrets)
127
- options.fetch(:signature).eql?(signature)
128
- ensure
129
- options.replace(original_options)
133
+ body_hash_valid? && Signature.verify(options.fetch(:signature_method), signing_key(options.merge(secrets)),
134
+ signature_base, options.fetch(:signature))
130
135
  end
131
136
 
132
137
  # Returns the OAuth attributes including the signature
@@ -161,11 +166,10 @@ module SimpleOAuth
161
166
  # @param body [String, nil] request body for body_hash computation
162
167
  # @return [Hash] merged OAuth options with defaults
163
168
  def build_options(oauth, body)
164
- if oauth.is_a?(Hash)
165
- self.class.default_options(body).merge(oauth.transform_keys(&:to_sym))
166
- else
167
- self.class.parse(oauth)
168
- end
169
+ return self.class.parse(oauth) unless oauth.is_a?(Hash)
170
+
171
+ overrides = oauth.transform_keys(&:to_sym)
172
+ self.class.default_options(body, overrides.fetch(:signature_method, DEFAULT_SIGNATURE_METHOD)).merge(overrides)
169
173
  end
170
174
 
171
175
  # Builds the normalized OAuth attributes string for the header
@@ -179,29 +183,6 @@ module SimpleOAuth
179
183
  .join(", ")
180
184
  end
181
185
 
182
- # Extracts valid OAuth attributes from options
183
- #
184
- # @api private
185
- # @return [Hash] OAuth attributes without signature or realm
186
- def attributes
187
- validate_option_keys!
188
- options.slice(*ATTRIBUTE_KEYS).transform_keys { |key| :"#{OAUTH_PREFIX}#{key}" }
189
- end
190
-
191
- # Validates that no unknown keys are present in options
192
- #
193
- # @api private
194
- # @raise [InvalidOptionsError] if extra keys are found
195
- # @return [void]
196
- def validate_option_keys!
197
- return if options[:ignore_extra_keys]
198
-
199
- extra_keys = options.keys - ATTRIBUTE_KEYS - IGNORED_KEYS
200
- return if extra_keys.empty?
201
-
202
- raise InvalidOptionsError, "Unknown option keys: #{extra_keys.map(&:inspect).join(", ")}"
203
- end
204
-
205
186
  # Returns OAuth attributes with realm for the Authorization header
206
187
  #
207
188
  # Per RFC 5849 Section 3.5.1, realm is included in the Authorization header
@@ -215,60 +196,54 @@ module SimpleOAuth
215
196
  attrs
216
197
  end
217
198
 
218
- # Extracts query parameters from the request URL
219
- #
220
- # @api private
221
- # @return [Array<Array>] URL query parameters as key-value pairs
222
- def url_params
223
- CGI.parse(@uri.query || "").flat_map do |key, values|
224
- values.sort.map { |value| [key, value] }
225
- end
226
- end
227
-
228
199
  # Computes the OAuth signature using the configured method
229
200
  #
230
201
  # @api private
231
202
  # @return [String] the computed signature based on signature_method
232
203
  def signature
233
- sig_method = options.fetch(:signature_method)
234
- sig_secret = Signature.rsa?(sig_method) ? options[:consumer_secret] : secret
235
- Signature.sign(sig_method, sig_secret, signature_base)
204
+ Signature.sign(options.fetch(:signature_method), signing_key(options), signature_base)
236
205
  end
237
206
 
238
- # Builds the secret string from consumer and token secrets
207
+ # The key for signing and verifying: an RSA key, or the escaped secrets
239
208
  #
240
209
  # @api private
241
- # @return [String] the secret string for signing
242
- def secret
243
- options.values_at(:consumer_secret, :token_secret).map { |v| Header.escape(v) }.join("&")
210
+ # @param options [Hash] the options holding the credentials
211
+ # @return [String, nil] the key
212
+ def signing_key(options)
213
+ Signature.rsa?(options.fetch(:signature_method)) ? options[:consumer_secret] : secret(options)
244
214
  end
245
215
 
246
- # Builds the signature base string from method, URL, and params
216
+ # Checks the body against the oauth_body_hash the header carries
217
+ #
218
+ # A header parsed from a request claims a body hash that its signature covers, so the claim must be
219
+ # checked against the body actually received. A signer that omits oauth_body_hash leaves the body
220
+ # unprotected, which its signature already attests to, so there is nothing to check.
247
221
  #
248
222
  # @api private
249
- # @return [String] the signature base string
250
- def signature_base
251
- [method, url, normalized_params].map { |v| Header.escape(v) }.join("&")
223
+ # @return [Boolean] true unless the body contradicts the signed oauth_body_hash
224
+ def body_hash_valid?
225
+ claimed_body_hash = options[:body_hash]
226
+ return true if body.nil? || claimed_body_hash.nil?
227
+
228
+ digest = Signature.digest(options.fetch(:signature_method))
229
+ OpenSSL.secure_compare(self.class.body_hash(body, digest), claimed_body_hash)
252
230
  end
253
231
 
254
- # Normalizes and sorts all request parameters for signing
232
+ # Builds the secret string from consumer and token secrets
255
233
  #
256
234
  # @api private
257
- # @return [String] normalized request parameters
258
- def normalized_params
259
- signature_params
260
- .map { |key, value| [Header.escape(key), Header.escape(value)] }
261
- .sort
262
- .map { |pair| pair.join("=") }
263
- .join("&")
235
+ # @param options [Hash] the options holding the secrets
236
+ # @return [String] the secret string for signing
237
+ def secret(options)
238
+ options.values_at(:consumer_secret, :token_secret).map { |v| Header.escape(v) }.join("&")
264
239
  end
265
240
 
266
- # Collects all parameters to include in signature
241
+ # Builds the signature base string from method, URL, and params
267
242
  #
268
243
  # @api private
269
- # @return [Array<Array>] all parameters for signature as key-value pairs
270
- def signature_params
271
- attributes.to_a + params.to_a + url_params
244
+ # @return [String] the signature base string
245
+ def signature_base
246
+ [method, url, normalized_params].map { |v| Header.escape(v) }.join("&")
272
247
  end
273
248
  end
274
249
  end
@@ -20,6 +20,9 @@ module SimpleOAuth
20
20
  # SimpleOAuth::Signature.registered?("HMAC-SHA1") # => true
21
21
  # SimpleOAuth::Signature.registered?("CUSTOM") # => false
22
22
  module Signature
23
+ # The hash algorithm of the signature methods RFC 5849 defines
24
+ DEFAULT_DIGEST = "SHA1".freeze
25
+
23
26
  # Registry of signature method implementations
24
27
  @registry = {}
25
28
 
@@ -29,6 +32,9 @@ module SimpleOAuth
29
32
  # @api public
30
33
  # @param name [String] the signature method name (e.g., "HMAC-SHA512")
31
34
  # @param rsa [Boolean] whether this method uses RSA (raw consumer_secret as key)
35
+ # @param verify [Proc, nil] a block that verifies a signature with a key, for methods whose
36
+ # signature cannot be recomputed from the verifying key, such as RSA with a public key
37
+ # @param digest [String] the hash algorithm this method signs with, which oauth_body_hash also uses
32
38
  # @yield [secret, signature_base] block that computes the signature
33
39
  # @yieldparam secret [String] the signing secret (or PEM key for RSA methods)
34
40
  # @yieldparam signature_base [String] the signature base string
@@ -40,8 +46,8 @@ module SimpleOAuth
40
46
  # OpenSSL::HMAC.digest("SHA512", secret, base)
41
47
  # )
42
48
  # end
43
- def register(name, rsa: false, &block)
44
- @registry[normalize_name(name)] = {implementation: block, rsa: rsa}
49
+ def register(name, rsa: false, verify: nil, digest: DEFAULT_DIGEST, &block)
50
+ @registry[normalize_name(name)] = {implementation: block, rsa: rsa, verifier: verify, digest: digest}
45
51
  end
46
52
 
47
53
  # Checks if a signature method is registered
@@ -77,6 +83,18 @@ module SimpleOAuth
77
83
  @registry.dig(normalize_name(name), :rsa) || false
78
84
  end
79
85
 
86
+ # Returns the hash algorithm a signature method signs with
87
+ #
88
+ # @api public
89
+ # @param name [String] the signature method name
90
+ # @return [String] the hash algorithm, such as SHA1 or SHA256
91
+ # @raise [ArgumentError] if the signature method is not registered
92
+ # @example
93
+ # SimpleOAuth::Signature.digest("HMAC-SHA256") # => "SHA256"
94
+ def digest(name)
95
+ fetch(name).fetch(:digest)
96
+ end
97
+
80
98
  # Computes a signature using the specified method
81
99
  #
82
100
  # @api public
@@ -88,12 +106,26 @@ module SimpleOAuth
88
106
  # @example
89
107
  # SimpleOAuth::Signature.sign("HMAC-SHA1", "secret&token", "GET&url&params")
90
108
  def sign(name, secret, signature_base)
91
- normalized = normalize_name(name)
92
- entry = @registry.fetch(normalized) do
93
- raise ArgumentError, "Unknown signature method: #{name}. " \
94
- "Registered methods: #{@registry.keys.join(", ")}"
95
- end
96
- entry.fetch(:implementation).call(secret, signature_base)
109
+ fetch(name).fetch(:implementation).call(secret, signature_base)
110
+ end
111
+
112
+ # Verifies a signature against a key and a signature base string
113
+ #
114
+ # @api public
115
+ # @param name [String] the signature method name
116
+ # @param key [String] the verifying key: a public or private RSA key, or the signing secret
117
+ # @param signature_base [String] the signature base string
118
+ # @param signature [String] the signature to verify
119
+ # @return [Boolean] true if the signature is valid
120
+ # @raise [ArgumentError] if the signature method is not registered
121
+ # @note Signatures are compared in constant time, so verifying leaks no timing information
122
+ # @example
123
+ # SimpleOAuth::Signature.verify("RSA-SHA1", public_key_pem, "GET&url&params", signature)
124
+ def verify(name, key, signature_base, signature)
125
+ verifier = fetch(name).fetch(:verifier)
126
+ return OpenSSL.secure_compare(sign(name, key, signature_base), signature) if verifier.nil?
127
+
128
+ verifier.call(key, signature_base, signature)
97
129
  end
98
130
 
99
131
  # Unregisters a signature method (useful for testing)
@@ -118,6 +150,18 @@ module SimpleOAuth
118
150
  register_builtin_methods
119
151
  end
120
152
 
153
+ # Decodes Base64-encoded data
154
+ #
155
+ # @api public
156
+ # @param data [String] Base64-encoded data
157
+ # @return [String] the decoded binary data
158
+ # @example
159
+ # SimpleOAuth::Signature.decode_base64("AQID")
160
+ # # => "\x01\x02\x03"
161
+ def decode_base64(data)
162
+ Base64.decode64(data)
163
+ end
164
+
121
165
  # Encodes binary data as Base64 without newlines
122
166
  #
123
167
  # @api public
@@ -132,6 +176,19 @@ module SimpleOAuth
132
176
 
133
177
  private
134
178
 
179
+ # Looks up a registered signature method
180
+ #
181
+ # @api private
182
+ # @param name [String] the signature method name
183
+ # @return [Hash] the registry entry
184
+ # @raise [ArgumentError] if the signature method is not registered
185
+ def fetch(name)
186
+ @registry.fetch(normalize_name(name)) do
187
+ raise ArgumentError, "Unknown signature method: #{name}. " \
188
+ "Registered methods: #{@registry.keys.join(", ")}"
189
+ end
190
+ end
191
+
135
192
  # Normalizes signature method name for registry lookup
136
193
  #
137
194
  # @api private
@@ -157,7 +214,7 @@ module SimpleOAuth
157
214
  # @return [void]
158
215
  def register_hmac_methods
159
216
  %w[SHA1 SHA256].each do |digest|
160
- register("HMAC-#{digest}") do |secret, signature_base|
217
+ register("HMAC-#{digest}", digest: digest) do |secret, signature_base|
161
218
  encode_base64(OpenSSL::HMAC.digest(digest, secret, signature_base))
162
219
  end
163
220
  end
@@ -169,7 +226,10 @@ module SimpleOAuth
169
226
  # @return [void]
170
227
  def register_rsa_methods
171
228
  %w[SHA1 SHA256].each do |digest|
172
- register("RSA-#{digest}", rsa: true) do |private_key_pem, signature_base|
229
+ verifier = lambda { |key_pem, signature_base, signature|
230
+ OpenSSL::PKey::RSA.new(key_pem).verify(digest, decode_base64(signature), signature_base)
231
+ }
232
+ register("RSA-#{digest}", rsa: true, verify: verifier, digest: digest) do |private_key_pem, signature_base|
173
233
  private_key = OpenSSL::PKey::RSA.new(private_key_pem)
174
234
  encode_base64(private_key.sign(digest, signature_base))
175
235
  end
@@ -1,5 +1,5 @@
1
1
  # OAuth 1.0 header generation library
2
2
  module SimpleOauth
3
3
  # The current version of the SimpleOAuth gem
4
- VERSION = "0.4.1".freeze
4
+ VERSION = "0.5.0".freeze
5
5
  end
data/sig/openssl_ext.rbs CHANGED
@@ -1,9 +1,15 @@
1
1
  # Extensions to OpenSSL types
2
2
  module OpenSSL
3
+ # Compare two strings in constant time
4
+ def self.secure_compare: (String a, String b) -> bool
5
+
3
6
  module PKey
4
7
  class PKey
5
8
  # Sign with digest name as String (in addition to Digest object)
6
9
  def sign: (String | OpenSSL::Digest digest, String data) -> String
10
+
11
+ # Verify with digest name as String (in addition to Digest object)
12
+ def verify: (String | OpenSSL::Digest digest, String signature, String data) -> bool
7
13
  end
8
14
  end
9
15
  end
@@ -1,9 +1,15 @@
1
1
  module SimpleOAuth
2
2
  class Header
3
3
  # Class methods for Header - parsing, defaults, and body hashing
4
- module ClassMethods
4
+ # What the ClassMethods module needs from the class that extends it
5
+ interface _HeaderFactory
6
+ def new: (String | Symbol method, String | URI::Generic url, Header::request_params params,
7
+ ?Header::oauth_options | String oauth, ?String? body) -> Header
8
+ end
9
+
10
+ module ClassMethods : _HeaderFactory
5
11
  # Returns default OAuth options with generated nonce and timestamp
6
- def default_options: (?String? body) -> Header::oauth_options
12
+ def default_options: (?String? body, ?String signature_method) -> Header::oauth_options
7
13
 
8
14
  # Computes the oauth_body_hash for a request body
9
15
  def body_hash: (String? body, ?String algorithm) -> String
@@ -12,10 +18,23 @@ module SimpleOAuth
12
18
  def parse: (String | _ToS header) -> Header::oauth_options
13
19
 
14
20
  # Parses OAuth parameters from a form-encoded POST body
21
+ # Builds a header for an HTTP request
22
+ def from_request: (Header::_Request request, ?Header::oauth_options | String oauth) -> Header
23
+
24
+ # Parses OAuth parameters from a query string
25
+ def parse_query: (String | _ToS body) -> Hash[Symbol, String]
26
+
15
27
  def parse_form_body: (String | _ToS body) -> Header::oauth_options
16
28
 
17
29
  private
18
30
 
31
+ # Checks whether a request carries a form-encoded body
32
+ def form_encoded?: (Header::_Request request) -> bool
33
+
34
+ # Extracts the media type from a request, without its parameters
35
+ def media_type: (Header::_Request request) -> String
36
+
37
+
19
38
  # Generates a random nonce for OAuth requests
20
39
  def generate_nonce: () -> String
21
40
 
@@ -0,0 +1,29 @@
1
+ module SimpleOAuth
2
+ class Header
3
+ # What the Params module needs from the header that includes it
4
+ interface _Signable
5
+ def options: () -> Header::oauth_options
6
+ def params: () -> Header::request_params
7
+ end
8
+
9
+ # Normalization of the parameters that are signed
10
+ module Params : _Signable
11
+ @uri: URI::Generic
12
+
13
+ private
14
+
15
+ # Extracts query parameters from the request URL
16
+ def url_params: () -> Array[untyped]
17
+ # Normalizes and sorts all request parameters for signing
18
+ def normalized_params: () -> String
19
+ # Collects all parameters to include in signature
20
+ def signature_params: () -> Array[untyped]
21
+ # Expands parameters into one pair per value, for parameters with several values
22
+ def expanded_params: () -> Array[untyped]
23
+ # Extracts valid OAuth attributes from options (excludes realm per RFC 5849)
24
+ def attributes: () -> Header::signed_attributes_hash
25
+ # Validates that no unknown keys are present in options
26
+ def validate_option_keys!: () -> void
27
+ end
28
+ end
29
+ end
@@ -5,16 +5,22 @@
5
5
  module SimpleOAuth
6
6
  module Signature
7
7
  # Type for signature implementation block
8
- type signature_block = ^(String secret, String signature_base) -> String
8
+ type signature_block = ^(untyped secret, String signature_base) -> String
9
+
10
+ # Type for a block that verifies a signature with a key
11
+ type verifier_block = ^(untyped key, String signature_base, String signature) -> bool
9
12
 
10
13
  # Type for registry entry
11
- type registry_entry = { implementation: signature_block, rsa: bool }
14
+ type registry_entry = { implementation: signature_block, rsa: bool, verifier: verifier_block?, digest: String }
15
+
16
+ # The hash algorithm of the signature methods RFC 5849 defines
17
+ DEFAULT_DIGEST: String
12
18
 
13
19
  # Registry of signature method implementations (class-level instance variable)
14
20
  self.@registry: Hash[String, registry_entry]
15
21
 
16
22
  # Registers a custom signature method
17
- def self.register: (String | Symbol name, ?rsa: bool) { (String, String) -> String } -> void
23
+ def self.register: (String | Symbol name, ?rsa: bool, ?verify: verifier_block?, ?digest: String) { (untyped, String) -> String } -> void
18
24
 
19
25
  # Checks if a signature method is registered
20
26
  def self.registered?: (String | Symbol name) -> bool
@@ -25,9 +31,15 @@ module SimpleOAuth
25
31
  # Checks if a signature method uses RSA
26
32
  def self.rsa?: (String | Symbol name) -> bool
27
33
 
34
+ # Returns the hash algorithm a signature method signs with
35
+ def self.digest: (String | Symbol name) -> String
36
+
28
37
  # Computes a signature using the specified method
29
38
  def self.sign: (String | Symbol name, String? secret, String signature_base) -> String
30
39
 
40
+ # Verifies a signature against a key and a signature base string
41
+ def self.verify: (String | Symbol name, String? key, String signature_base, String signature) -> bool
42
+
31
43
  # Unregisters a signature method
32
44
  def self.unregister: (String | Symbol name) -> void
33
45
 
@@ -37,8 +49,14 @@ module SimpleOAuth
37
49
  # Encodes binary data as Base64 without newlines
38
50
  def self.encode_base64: (String data) -> String
39
51
 
52
+ # Decodes Base64-encoded data
53
+ def self.decode_base64: (String data) -> String
54
+
40
55
  private
41
56
 
57
+ # Looks up a registered signature method
58
+ def self.fetch: (String | Symbol name) -> registry_entry
59
+
42
60
  # Normalizes signature method name for registry lookup
43
61
  def self.normalize_name: (String | Symbol name) -> String
44
62
 
data/sig/simple_oauth.rbs CHANGED
@@ -40,6 +40,9 @@ module SimpleOAuth
40
40
  # Prefix for OAuth parameters
41
41
  OAUTH_PREFIX: String
42
42
 
43
+ # The content type whose body parameters are signed
44
+ FORM_CONTENT_TYPE: String
45
+
43
46
  # Default signature method per RFC 5849
44
47
  DEFAULT_SIGNATURE_METHOD: String
45
48
 
@@ -55,11 +58,21 @@ module SimpleOAuth
55
58
  # Valid keys when parsing OAuth parameters (ATTRIBUTE_KEYS + signature)
56
59
  PARSE_KEYS: Array[Symbol]
57
60
 
61
+ # What from_request needs from a request object, such as a Net::HTTPRequest
62
+ interface _Request
63
+ def method: () -> String
64
+ def uri: () -> URI::Generic?
65
+ def body: () -> String?
66
+ def []: (String name) -> String?
67
+ end
68
+
58
69
  # Type aliases for clarity
59
70
  type oauth_key = :body_hash | :callback | :consumer_key | :nonce | :signature_method | :timestamp | :token | :verifier | :version
60
71
  type ignored_key = :consumer_secret | :token_secret | :signature | :realm | :ignore_extra_keys
61
72
  type signature_method = "HMAC-SHA1" | "HMAC-SHA256" | "RSA-SHA1" | "RSA-SHA256" | "PLAINTEXT"
62
73
  type params_hash = Hash[String | Symbol, untyped]
74
+ type params_pairs = Array[[String | Symbol, untyped]]
75
+ type request_params = params_hash | params_pairs
63
76
  type oauth_options = Hash[Symbol, untyped]
64
77
  type signed_attributes_hash = Hash[Symbol, untyped]
65
78
 
@@ -67,7 +80,7 @@ module SimpleOAuth
67
80
  attr_reader method: String
68
81
 
69
82
  # The request parameters to be signed
70
- attr_reader params: params_hash
83
+ attr_reader params: request_params
71
84
 
72
85
  # The raw request body for oauth_body_hash computation
73
86
  attr_reader body: String?
@@ -75,6 +88,9 @@ module SimpleOAuth
75
88
  # The OAuth options including credentials and signature
76
89
  attr_reader options: oauth_options
77
90
 
91
+ # Parameter and attribute normalization from the Params module
92
+ include Params
93
+
78
94
  # Class methods from ClassMethods module
79
95
  extend ClassMethods
80
96
 
@@ -94,7 +110,7 @@ module SimpleOAuth
94
110
  def self.decode: (String | _ToS value) -> String
95
111
 
96
112
  # Creates a new OAuth header
97
- def initialize: (String | Symbol method, String | URI::Generic url, params_hash params, ?oauth_options | String oauth, ?String? body) -> void
113
+ def initialize: (String | Symbol method, String | URI::Generic url, request_params params, ?oauth_options | String oauth, ?String? body) -> void
98
114
 
99
115
  # Returns the normalized URL without query string or fragment
100
116
  def url: () -> String
@@ -122,32 +138,24 @@ module SimpleOAuth
122
138
  # Builds the normalized OAuth attributes string for the Authorization header
123
139
  def normalized_attributes: () -> String
124
140
 
125
- # Extracts valid OAuth attributes from options (excludes realm per RFC 5849)
126
- def attributes: () -> signed_attributes_hash
127
-
128
- # Validates that no unknown keys are present in options
129
- def validate_option_keys!: () -> void
130
-
131
141
  # Returns OAuth attributes including realm for Authorization header output
132
142
  def header_attributes: () -> signed_attributes_hash
133
143
 
134
- # Extracts query parameters from the request URL
135
- def url_params: () -> Array[untyped]
144
+ # The key for signing and verifying
145
+ def signing_key: (oauth_options options) -> String?
146
+
147
+ # Checks the body against the oauth_body_hash the header carries
148
+ def body_hash_valid?: () -> bool
136
149
 
137
150
  # Computes the OAuth signature using the configured signature method
138
151
  def signature: () -> String
139
152
 
140
153
  # Builds the secret string from consumer and token secrets
141
- def secret: () -> String
154
+ def secret: (oauth_options options) -> String
142
155
 
143
156
  # Builds the signature base string from method, URL, and params
144
157
  def signature_base: () -> String
145
158
 
146
- # Normalizes and sorts all request parameters for signing
147
- def normalized_params: () -> String
148
-
149
- # Collects all parameters to include in signature
150
- def signature_params: () -> Array[untyped]
151
159
  end
152
160
  end
153
161
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simple_oauth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steve Richert
@@ -59,6 +59,7 @@ files:
59
59
  - lib/simple_oauth/errors.rb
60
60
  - lib/simple_oauth/header.rb
61
61
  - lib/simple_oauth/header/class_methods.rb
62
+ - lib/simple_oauth/header/params.rb
62
63
  - lib/simple_oauth/parser.rb
63
64
  - lib/simple_oauth/signature.rb
64
65
  - lib/simple_oauth/version.rb
@@ -67,6 +68,7 @@ files:
67
68
  - sig/openssl_ext.rbs
68
69
  - sig/simple_oauth.rbs
69
70
  - sig/simple_oauth/header/class_methods.rbs
71
+ - sig/simple_oauth/header/params.rbs
70
72
  - sig/simple_oauth/parser.rbs
71
73
  - sig/simple_oauth/signature.rbs
72
74
  - sig/strscan.rbs
@@ -94,7 +96,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
94
96
  - !ruby/object:Gem::Version
95
97
  version: '0'
96
98
  requirements: []
97
- rubygems_version: 4.0.10
99
+ rubygems_version: 4.0.20
98
100
  specification_version: 4
99
101
  summary: Simply builds and verifies OAuth headers
100
102
  test_files: []