simple_oauth 0.4.2 → 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: 5bce3e8cdfffe90e9a914e19705c101634ef6bccb620f1d7305761220ce104d4
4
- data.tar.gz: 749264b00267e865c764beb8ea77429ecfb02f3d403b681a4734d40dd218d9f2
3
+ metadata.gz: 7eca3b8d3940706967367e25b8f42261d48b325c8f2cd1c1cc01ad58a800f284
4
+ data.tar.gz: 63cec8ec4204dd64c40fdf61ecf1782df0364687794896199ded8a6dcb890ca3
5
5
  SHA512:
6
- metadata.gz: bea32b53aafa6fb4d1a4d1dc4df26012899b2ae0015f533964a910e65b1443ae79b87a300ef91fcb24c731d11746f1a579fa31b2dce783f0caf64b0c33f0afe5
7
- data.tar.gz: e9af430b5be60f4441402b28bd8932ff178fe327889c2f8c7fca778616fc8298a86c185fbc7d03476f3b5d84e74f30dbd455f0fe2c8d649f91d79ceb1d25e2ce
6
+ metadata.gz: 967d4c8e1f174da75c8818fa0b94432ca2878d44474e5433930aa5bca728d4636b1ae4594e930be98fc68d6d9d57a40622dd5075ff507242100f424b95213e75
7
+ data.tar.gz: 7b24159117153d934834c2767692be078a78c58ec6fc34775d91bd90794767bbf86cbde9fb6306bcf5640b2a7b212f8bd0866e980ed3a871a3e0212120a7494b
data/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
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
+
1
21
  ## [0.4.2] - 2026-09-12
2
22
 
3
23
  ### 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, 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
 
@@ -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
 
@@ -118,16 +123,15 @@ module SimpleOAuth
118
123
  # @api public
119
124
  # @param secrets [Hash] the consumer_secret and token_secret for validation
120
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
121
128
  # @example
122
129
  # parsed_header = SimpleOAuth::Header.new(:get, url, {}, authorization_header)
123
130
  # parsed_header.valid?(consumer_secret: "secret", token_secret: "token_secret")
124
131
  # # => true
125
132
  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)
133
+ body_hash_valid? && Signature.verify(options.fetch(:signature_method), signing_key(options.merge(secrets)),
134
+ signature_base, options.fetch(:signature))
131
135
  end
132
136
 
133
137
  # Returns the OAuth attributes including the signature
@@ -162,11 +166,10 @@ module SimpleOAuth
162
166
  # @param body [String, nil] request body for body_hash computation
163
167
  # @return [Hash] merged OAuth options with defaults
164
168
  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
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)
170
173
  end
171
174
 
172
175
  # Builds the normalized OAuth attributes string for the header
@@ -180,29 +183,6 @@ module SimpleOAuth
180
183
  .join(", ")
181
184
  end
182
185
 
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
186
  # Returns OAuth attributes with realm for the Authorization header
207
187
  #
208
188
  # Per RFC 5849 Section 3.5.1, realm is included in the Authorization header
@@ -216,60 +196,54 @@ module SimpleOAuth
216
196
  attrs
217
197
  end
218
198
 
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
199
  # Computes the OAuth signature using the configured method
230
200
  #
231
201
  # @api private
232
202
  # @return [String] the computed signature based on signature_method
233
203
  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)
204
+ Signature.sign(options.fetch(:signature_method), signing_key(options), signature_base)
237
205
  end
238
206
 
239
- # Builds the secret string from consumer and token secrets
207
+ # The key for signing and verifying: an RSA key, or the escaped secrets
240
208
  #
241
209
  # @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("&")
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)
245
214
  end
246
215
 
247
- # 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.
248
221
  #
249
222
  # @api private
250
- # @return [String] the signature base string
251
- def signature_base
252
- [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)
253
230
  end
254
231
 
255
- # Normalizes and sorts all request parameters for signing
232
+ # Builds the secret string from consumer and token secrets
256
233
  #
257
234
  # @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("&")
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("&")
265
239
  end
266
240
 
267
- # Collects all parameters to include in signature
241
+ # Builds the signature base string from method, URL, and params
268
242
  #
269
243
  # @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
244
+ # @return [String] the signature base string
245
+ def signature_base
246
+ [method, url, normalized_params].map { |v| Header.escape(v) }.join("&")
273
247
  end
274
248
  end
275
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.2".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,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.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