simple_oauth 0.4.2 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5bce3e8cdfffe90e9a914e19705c101634ef6bccb620f1d7305761220ce104d4
4
- data.tar.gz: 749264b00267e865c764beb8ea77429ecfb02f3d403b681a4734d40dd218d9f2
3
+ metadata.gz: 680161f3f3fc8cf54f78c5111c1f0c4bcd6380b0d29022fb9ac5fd407805d8b7
4
+ data.tar.gz: 6951095c0b1b04b7755e8857a977946825053a141e3fd4c1c3b7e10cade061fe
5
5
  SHA512:
6
- metadata.gz: bea32b53aafa6fb4d1a4d1dc4df26012899b2ae0015f533964a910e65b1443ae79b87a300ef91fcb24c731d11746f1a579fa31b2dce783f0caf64b0c33f0afe5
7
- data.tar.gz: e9af430b5be60f4441402b28bd8932ff178fe327889c2f8c7fca778616fc8298a86c185fbc7d03476f3b5d84e74f30dbd455f0fe2c8d649f91d79ceb1d25e2ce
6
+ metadata.gz: f852606e3aa48e70a5856f0cfccbfafe4c9235a6a701faca692446186b5bf8f30781b316577b7ac15a164b07293f9b550ad5cdfe0db58df6e57be396de380cf6
7
+ data.tar.gz: d938005d0ad30817b294e0e8143b272be0dafb794dfa4a57ee012fc8f162c7d895d85a5a2ae0a57f4bc3b9e50c372fde93ac22b31bc01579ead3deec968f2d2a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,30 @@
1
+ ## [0.5.1] - 2026-09-12
2
+
3
+ ### Fixed
4
+
5
+ * Build the signature base string with a String conversion that every supported Ruby offers; `Header#url` called `URI::Generic#to_str`, which arrived in `uri` 0.13, so it raised `NoMethodError` on a stock Ruby 3.2, the oldest version the gem claims to support
6
+ * Sign a parameter that carries no value, such as a bare `?flag` in the query string or the `c2` of the RFC 5849 Section 3.4.1.3.1 example, as `name=` rather than dropping it from the signature base string; a request carrying one signed differently than the server computes, so it was rejected
7
+
8
+ ## [0.5.0] - 2026-09-12
9
+
10
+ ### Added
11
+
12
+ * `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
13
+ * `Header.parse_query`, for OAuth credentials sent in a query string
14
+ * `Signature.digest`, and a `digest:` option on `Signature.register`, which gives the hash algorithm a signature method signs with
15
+ * `Signature.verify` and a `verify:` option on `Signature.register`, for signature methods that cannot be verified by recomputing the signature
16
+ * `Signature.decode_base64`
17
+
18
+ ### Fixed
19
+
20
+ * 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
21
+ * Verify signatures without merging the given secrets into the header's own options, where anything else reading the header could see them
22
+ * Compare signatures in constant time when verifying
23
+ * Compute `oauth_body_hash` with the hash algorithm of the signature method, such as SHA-256 for HMAC-SHA256; it was always SHA-1
24
+ * 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
25
+ * 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
26
+ * 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
27
+
1
28
  ## [0.4.2] - 2026-09-12
2
29
 
3
30
  ### Added
data/README.md CHANGED
@@ -40,11 +40,29 @@ 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
+
43
56
  ### Repeated Parameters
44
57
 
45
- Pass parameters as an Array of key-value pairs when a key repeats:
58
+ Pass an Array of values, or an Array of key-value pairs, when a key repeats:
46
59
 
47
60
  ```ruby
61
+ header = SimpleOAuth::Header.new(:post, url, {"ids" => %w[1 2]},
62
+ consumer_key: "key",
63
+ consumer_secret: "secret"
64
+ )
65
+
48
66
  header = SimpleOAuth::Header.new(:post, url, [["ids", "1"], ["ids", "2"]],
49
67
  consumer_key: "key",
50
68
  consumer_secret: "secret"
@@ -87,7 +105,7 @@ SimpleOAuth::Signature.methods # => ["hmac_sha1", "hmac_sha256", "rsa_sha1", "rs
87
105
 
88
106
  ### OAuth Request Body Hash
89
107
 
90
- 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.
91
109
 
92
110
  ```ruby
93
111
  json_body = '{"text": "Hello, World!"}'
@@ -120,11 +138,14 @@ parsed = SimpleOAuth::Header.parse('OAuth oauth_consumer_key="key", oauth_signat
120
138
  # => {consumer_key: "key", signature: "sig"}
121
139
  ```
122
140
 
123
- Parse OAuth credentials from a form-encoded POST body:
141
+ Parse OAuth credentials from a form-encoded POST body, or from a query string:
124
142
 
125
143
  ```ruby
126
144
  parsed = SimpleOAuth::Header.parse_form_body('oauth_consumer_key=key&oauth_signature=sig&status=hello')
127
145
  # => {consumer_key: "key", signature: "sig"}
146
+
147
+ parsed = SimpleOAuth::Header.parse_query("oauth_consumer_key=key&status=hello")
148
+ # => {consumer_key: "key"}
128
149
  ```
129
150
 
130
151
  ### Verifying Signatures
@@ -138,6 +159,25 @@ header.valid?(consumer_secret: "secret", token_secret: "token_secret")
138
159
  # => true
139
160
  ```
140
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
+
141
181
  ## Contributing
142
182
 
143
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, form_params(body), 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,55 @@ 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
+ # Parses a form-encoded body into the parameter pairs to sign
121
+ #
122
+ # A parameter with no value, such as the "c2" of the RFC 5849 Section 3.4.1.3.1
123
+ # example, is signed with an empty value rather than dropped.
124
+ #
125
+ # @api private
126
+ # @param body [String, nil] the form-encoded body
127
+ # @return [Array<Array(String, String)>] the parameter pairs
128
+ def form_params(body)
129
+ CGI.parse(body.to_s).flat_map do |key, values|
130
+ # A parameter with no value still makes one pair, carrying an empty value
131
+ (values.empty? ? [""] : values).map { |value| [key, value] }
132
+ end
133
+ end
134
+
135
+ # Checks whether a request carries a form-encoded body
136
+ #
137
+ # @api private
138
+ # @param request [#[]] the request
139
+ # @return [Boolean] true if the body is form-encoded
140
+ def form_encoded?(request)
141
+ media_type(request).eql?(FORM_CONTENT_TYPE)
142
+ end
143
+
144
+ # Extracts the media type from a request, without its parameters
145
+ #
146
+ # Per RFC 9110 Section 8.3 the media type is case-insensitive and may carry parameters,
147
+ # such as a charset, that play no part in identifying it.
148
+ #
149
+ # @api private
150
+ # @param request [#[]] the request
151
+ # @return [String] the lowercase media type, or an empty String when the request declares none
152
+ def media_type(request)
153
+ request["Content-Type"].to_s.split(";").first.to_s.strip.downcase
154
+ end
155
+
81
156
  # Generates a random nonce for OAuth requests
82
157
  #
83
158
  # @api private
@@ -0,0 +1,79 @@
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
+ # A parameter with no value, such as the "c2" of the RFC 5849 Section 3.4.1.3.1
37
+ # example, is signed with an empty value rather than dropped.
38
+ #
39
+ # @api private
40
+ # @return [Array<Array>] URL query parameters as key-value pairs
41
+ def url_params
42
+ CGI.parse(@uri.query || "").flat_map do |key, values|
43
+ # A parameter with no value still makes one pair, carrying an empty value
44
+ (values.empty? ? [""] : values.sort).map { |value| [key, value] }
45
+ end
46
+ end
47
+
48
+ # Normalizes and sorts all request parameters for signing
49
+ #
50
+ # @api private
51
+ # @return [String] normalized request parameters
52
+ def normalized_params
53
+ signature_params
54
+ .map { |key, value| [Header.escape(key), Header.escape(value)] }
55
+ .sort
56
+ .map { |pair| pair.join("=") }
57
+ .join("&")
58
+ end
59
+
60
+ # Collects all parameters to include in signature
61
+ #
62
+ # @api private
63
+ # @return [Array<Array>] all parameters for signature as key-value pairs
64
+ def signature_params
65
+ attributes.to_a + expanded_params + url_params
66
+ end
67
+
68
+ # Expands parameters into one pair per value, for parameters with several values
69
+ #
70
+ # @api private
71
+ # @return [Array<Array(Object, Object)>] the parameter pairs
72
+ def expanded_params
73
+ params.flat_map do |key, value|
74
+ value.is_a?(Array) ? value.map { |element| [key, element] } : [[key, value]]
75
+ end
76
+ end
77
+ end
78
+ end
79
+ 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
 
@@ -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
 
@@ -97,7 +102,9 @@ module SimpleOAuth
97
102
  # header.url
98
103
  # # => "https://api.example.com/path"
99
104
  def url
100
- @uri.dup.tap { |uri| uri.query = nil }.to_str
105
+ # String() takes whichever conversion the installed uri defines: it gained to_str in
106
+ # 0.13, and the uri that ships with Ruby 3.2 offers only to_s
107
+ String(@uri.dup.tap { |uri| uri.query = nil })
101
108
  end
102
109
 
103
110
  # Returns the OAuth Authorization header string
@@ -118,16 +125,15 @@ module SimpleOAuth
118
125
  # @api public
119
126
  # @param secrets [Hash] the consumer_secret and token_secret for validation
120
127
  # @return [Boolean] true if the signature is valid, false otherwise
128
+ # @note When the header was built with a body, the signed oauth_body_hash must match that body,
129
+ # so a tampered body fails verification even though its signature covers the claimed hash
121
130
  # @example
122
131
  # parsed_header = SimpleOAuth::Header.new(:get, url, {}, authorization_header)
123
132
  # parsed_header.valid?(consumer_secret: "secret", token_secret: "token_secret")
124
133
  # # => true
125
134
  def valid?(secrets = {})
126
- original_options = options.dup #: Hash[Symbol, untyped]
127
- options.merge!(secrets)
128
- options.fetch(:signature).eql?(signature)
129
- ensure
130
- options.replace(original_options)
135
+ body_hash_valid? && Signature.verify(options.fetch(:signature_method), signing_key(options.merge(secrets)),
136
+ signature_base, options.fetch(:signature))
131
137
  end
132
138
 
133
139
  # Returns the OAuth attributes including the signature
@@ -162,11 +168,10 @@ module SimpleOAuth
162
168
  # @param body [String, nil] request body for body_hash computation
163
169
  # @return [Hash] merged OAuth options with defaults
164
170
  def build_options(oauth, body)
165
- if oauth.is_a?(Hash)
166
- self.class.default_options(body).merge(oauth.transform_keys(&:to_sym))
167
- else
168
- self.class.parse(oauth)
169
- end
171
+ return self.class.parse(oauth) unless oauth.is_a?(Hash)
172
+
173
+ overrides = oauth.transform_keys(&:to_sym)
174
+ self.class.default_options(body, overrides.fetch(:signature_method, DEFAULT_SIGNATURE_METHOD)).merge(overrides)
170
175
  end
171
176
 
172
177
  # Builds the normalized OAuth attributes string for the header
@@ -180,29 +185,6 @@ module SimpleOAuth
180
185
  .join(", ")
181
186
  end
182
187
 
183
- # Extracts valid OAuth attributes from options
184
- #
185
- # @api private
186
- # @return [Hash] OAuth attributes without signature or realm
187
- def attributes
188
- validate_option_keys!
189
- options.slice(*ATTRIBUTE_KEYS).transform_keys { |key| :"#{OAUTH_PREFIX}#{key}" }
190
- end
191
-
192
- # Validates that no unknown keys are present in options
193
- #
194
- # @api private
195
- # @raise [InvalidOptionsError] if extra keys are found
196
- # @return [void]
197
- def validate_option_keys!
198
- return if options[:ignore_extra_keys]
199
-
200
- extra_keys = options.keys - ATTRIBUTE_KEYS - IGNORED_KEYS
201
- return if extra_keys.empty?
202
-
203
- raise InvalidOptionsError, "Unknown option keys: #{extra_keys.map(&:inspect).join(", ")}"
204
- end
205
-
206
188
  # Returns OAuth attributes with realm for the Authorization header
207
189
  #
208
190
  # Per RFC 5849 Section 3.5.1, realm is included in the Authorization header
@@ -216,60 +198,54 @@ module SimpleOAuth
216
198
  attrs
217
199
  end
218
200
 
219
- # Extracts query parameters from the request URL
220
- #
221
- # @api private
222
- # @return [Array<Array>] URL query parameters as key-value pairs
223
- def url_params
224
- CGI.parse(@uri.query || "").flat_map do |key, values|
225
- values.sort.map { |value| [key, value] }
226
- end
227
- end
228
-
229
201
  # Computes the OAuth signature using the configured method
230
202
  #
231
203
  # @api private
232
204
  # @return [String] the computed signature based on signature_method
233
205
  def signature
234
- sig_method = options.fetch(:signature_method)
235
- sig_secret = Signature.rsa?(sig_method) ? options[:consumer_secret] : secret
236
- Signature.sign(sig_method, sig_secret, signature_base)
206
+ Signature.sign(options.fetch(:signature_method), signing_key(options), signature_base)
237
207
  end
238
208
 
239
- # Builds the secret string from consumer and token secrets
209
+ # The key for signing and verifying: an RSA key, or the escaped secrets
240
210
  #
241
211
  # @api private
242
- # @return [String] the secret string for signing
243
- def secret
244
- options.values_at(:consumer_secret, :token_secret).map { |v| Header.escape(v) }.join("&")
212
+ # @param options [Hash] the options holding the credentials
213
+ # @return [String, nil] the key
214
+ def signing_key(options)
215
+ Signature.rsa?(options.fetch(:signature_method)) ? options[:consumer_secret] : secret(options)
245
216
  end
246
217
 
247
- # Builds the signature base string from method, URL, and params
218
+ # Checks the body against the oauth_body_hash the header carries
219
+ #
220
+ # A header parsed from a request claims a body hash that its signature covers, so the claim must be
221
+ # checked against the body actually received. A signer that omits oauth_body_hash leaves the body
222
+ # unprotected, which its signature already attests to, so there is nothing to check.
248
223
  #
249
224
  # @api private
250
- # @return [String] the signature base string
251
- def signature_base
252
- [method, url, normalized_params].map { |v| Header.escape(v) }.join("&")
225
+ # @return [Boolean] true unless the body contradicts the signed oauth_body_hash
226
+ def body_hash_valid?
227
+ claimed_body_hash = options[:body_hash]
228
+ return true if body.nil? || claimed_body_hash.nil?
229
+
230
+ digest = Signature.digest(options.fetch(:signature_method))
231
+ OpenSSL.secure_compare(self.class.body_hash(body, digest), claimed_body_hash)
253
232
  end
254
233
 
255
- # Normalizes and sorts all request parameters for signing
234
+ # Builds the secret string from consumer and token secrets
256
235
  #
257
236
  # @api private
258
- # @return [String] normalized request parameters
259
- def normalized_params
260
- signature_params
261
- .map { |key, value| [Header.escape(key), Header.escape(value)] }
262
- .sort
263
- .map { |pair| pair.join("=") }
264
- .join("&")
237
+ # @param options [Hash] the options holding the secrets
238
+ # @return [String] the secret string for signing
239
+ def secret(options)
240
+ options.values_at(:consumer_secret, :token_secret).map { |v| Header.escape(v) }.join("&")
265
241
  end
266
242
 
267
- # Collects all parameters to include in signature
243
+ # Builds the signature base string from method, URL, and params
268
244
  #
269
245
  # @api private
270
- # @return [Array<Array>] all parameters for signature as key-value pairs
271
- def signature_params
272
- attributes.to_a + params.to_a + url_params
246
+ # @return [String] the signature base string
247
+ def signature_base
248
+ [method, url, normalized_params].map { |v| Header.escape(v) }.join("&")
273
249
  end
274
250
  end
275
251
  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.2".freeze
4
+ VERSION = "0.5.1".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,26 @@ 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
+ # Parses a form-encoded body into the parameter pairs to sign
32
+ def form_params: (String? body) -> Array[[String, String]]
33
+
34
+ # Checks whether a request carries a form-encoded body
35
+ def form_encoded?: (Header::_Request request) -> bool
36
+
37
+ # Extracts the media type from a request, without its parameters
38
+ def media_type: (Header::_Request request) -> String
39
+
40
+
19
41
  # Generates a random nonce for OAuth requests
20
42
  def generate_nonce: () -> String
21
43
 
@@ -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,6 +58,14 @@ 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
@@ -77,6 +88,9 @@ module SimpleOAuth
77
88
  # The OAuth options including credentials and signature
78
89
  attr_reader options: oauth_options
79
90
 
91
+ # Parameter and attribute normalization from the Params module
92
+ include Params
93
+
80
94
  # Class methods from ClassMethods module
81
95
  extend ClassMethods
82
96
 
@@ -124,32 +138,24 @@ module SimpleOAuth
124
138
  # Builds the normalized OAuth attributes string for the Authorization header
125
139
  def normalized_attributes: () -> String
126
140
 
127
- # Extracts valid OAuth attributes from options (excludes realm per RFC 5849)
128
- def attributes: () -> signed_attributes_hash
129
-
130
- # Validates that no unknown keys are present in options
131
- def validate_option_keys!: () -> void
132
-
133
141
  # Returns OAuth attributes including realm for Authorization header output
134
142
  def header_attributes: () -> signed_attributes_hash
135
143
 
136
- # Extracts query parameters from the request URL
137
- 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
138
149
 
139
150
  # Computes the OAuth signature using the configured signature method
140
151
  def signature: () -> String
141
152
 
142
153
  # Builds the secret string from consumer and token secrets
143
- def secret: () -> String
154
+ def secret: (oauth_options options) -> String
144
155
 
145
156
  # Builds the signature base string from method, URL, and params
146
157
  def signature_base: () -> String
147
158
 
148
- # Normalizes and sorts all request parameters for signing
149
- def normalized_params: () -> String
150
-
151
- # Collects all parameters to include in signature
152
- def signature_params: () -> Array[untyped]
153
159
  end
154
160
  end
155
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.2
4
+ version: 0.5.1
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,10 +68,10 @@ 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
73
- - sig/uri_ext.rbs
74
75
  homepage: https://github.com/laserlemon/simple_oauth
75
76
  licenses:
76
77
  - MIT
data/sig/uri_ext.rbs DELETED
@@ -1,7 +0,0 @@
1
- # Extensions to the URI module
2
- module URI
3
- class Generic
4
- # Returns the URI as a String (implicit conversion)
5
- def to_str: () -> String
6
- end
7
- end