oauth2 1.4.9 → 2.0.17

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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +706 -88
  4. data/CITATION.cff +20 -0
  5. data/CODE_OF_CONDUCT.md +24 -23
  6. data/CONTRIBUTING.md +229 -0
  7. data/FUNDING.md +77 -0
  8. data/{LICENSE → LICENSE.txt} +2 -2
  9. data/OIDC.md +158 -0
  10. data/README.md +1513 -251
  11. data/REEK +0 -0
  12. data/RUBOCOP.md +71 -0
  13. data/SECURITY.md +21 -0
  14. data/lib/oauth2/access_token.rb +276 -39
  15. data/lib/oauth2/authenticator.rb +45 -8
  16. data/lib/oauth2/client.rb +406 -129
  17. data/lib/oauth2/error.rb +59 -24
  18. data/lib/oauth2/filtered_attributes.rb +52 -0
  19. data/lib/oauth2/response.rb +127 -36
  20. data/lib/oauth2/strategy/assertion.rb +68 -40
  21. data/lib/oauth2/strategy/auth_code.rb +25 -4
  22. data/lib/oauth2/strategy/client_credentials.rb +3 -3
  23. data/lib/oauth2/strategy/implicit.rb +17 -2
  24. data/lib/oauth2/strategy/password.rb +14 -4
  25. data/lib/oauth2/version.rb +1 -59
  26. data/lib/oauth2.rb +79 -12
  27. data/sig/oauth2/access_token.rbs +25 -0
  28. data/sig/oauth2/authenticator.rbs +22 -0
  29. data/sig/oauth2/client.rbs +52 -0
  30. data/sig/oauth2/error.rbs +8 -0
  31. data/sig/oauth2/filtered_attributes.rbs +6 -0
  32. data/sig/oauth2/response.rbs +18 -0
  33. data/sig/oauth2/strategy.rbs +34 -0
  34. data/sig/oauth2/version.rbs +5 -0
  35. data/sig/oauth2.rbs +9 -0
  36. data.tar.gz.sig +0 -0
  37. metadata +336 -89
  38. metadata.gz.sig +0 -0
  39. data/lib/oauth2/mac_token.rb +0 -130
  40. data/spec/fixtures/README.md +0 -11
  41. data/spec/fixtures/RS256/jwtRS256.key +0 -51
  42. data/spec/fixtures/RS256/jwtRS256.key.pub +0 -14
  43. data/spec/helper.rb +0 -33
  44. data/spec/oauth2/access_token_spec.rb +0 -218
  45. data/spec/oauth2/authenticator_spec.rb +0 -86
  46. data/spec/oauth2/client_spec.rb +0 -556
  47. data/spec/oauth2/mac_token_spec.rb +0 -122
  48. data/spec/oauth2/response_spec.rb +0 -96
  49. data/spec/oauth2/strategy/assertion_spec.rb +0 -113
  50. data/spec/oauth2/strategy/auth_code_spec.rb +0 -108
  51. data/spec/oauth2/strategy/base_spec.rb +0 -7
  52. data/spec/oauth2/strategy/client_credentials_spec.rb +0 -71
  53. data/spec/oauth2/strategy/implicit_spec.rb +0 -28
  54. data/spec/oauth2/strategy/password_spec.rb +0 -58
  55. data/spec/oauth2/version_spec.rb +0 -23
data/lib/oauth2/client.rb CHANGED
@@ -1,36 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'faraday'
4
- require 'logger'
3
+ require "faraday"
4
+ require "logger"
5
+
6
+ # :nocov: since coverage tracking only runs on the builds with Faraday v2
7
+ # We do run builds on Faraday v0 (and v1!), so this code is actually covered!
8
+ # This is the only nocov in the whole project!
9
+ if Faraday::Utils.respond_to?(:default_space_encoding)
10
+ # This setting doesn't exist in faraday 0.x
11
+ Faraday::Utils.default_space_encoding = "%20"
12
+ end
13
+ # :nocov:
5
14
 
6
15
  module OAuth2
7
16
  ConnectionError = Class.new(Faraday::ConnectionFailed)
17
+ TimeoutError = Class.new(Faraday::TimeoutError)
18
+
8
19
  # The OAuth2::Client class
9
20
  class Client # rubocop:disable Metrics/ClassLength
10
- RESERVED_PARAM_KEYS = %w[headers parse].freeze
21
+ RESERVED_REQ_KEYS = %w[body headers params redirect_count].freeze
22
+ RESERVED_PARAM_KEYS = (RESERVED_REQ_KEYS + %w[parse snaky snaky_hash_klass token_method]).freeze
23
+
24
+ include FilteredAttributes
11
25
 
12
26
  attr_reader :id, :secret, :site
13
27
  attr_accessor :options
14
28
  attr_writer :connection
29
+ filtered_attributes :secret
15
30
 
16
- # Instantiate a new OAuth 2.0 client using the
17
- # Client ID and Client Secret registered to your
18
- # application.
31
+ # Initializes a new OAuth2::Client instance using the Client ID and Client Secret registered to your application.
19
32
  #
20
33
  # @param [String] client_id the client_id value
21
34
  # @param [String] client_secret the client_secret value
22
- # @param [Hash] options the options to create the client with
35
+ # @param [Hash] options the options to configure the client
23
36
  # @option options [String] :site the OAuth2 provider site host
24
- # @option options [String] :redirect_uri the absolute URI to the Redirection Endpoint for use in authorization grants and token exchange
25
- # @option options [String] :authorize_url ('oauth/authorize') absolute or relative URL path to the Authorization endpoint
26
- # @option options [String] :token_url ('oauth/token') absolute or relative URL path to the Token endpoint
27
- # @option options [Symbol] :token_method (:post) HTTP method to use to request token (:get or :post)
28
- # @option options [Symbol] :auth_scheme (:basic_auth) HTTP method to use to authorize request (:basic_auth or :request_body)
29
- # @option options [Hash] :connection_opts ({}) Hash of connection options to pass to initialize Faraday with
30
- # @option options [FixNum] :max_redirects (5) maximum number of redirects to follow
31
- # @option options [Boolean] :raise_errors (true) whether or not to raise an OAuth2::Error on responses with 400+ status codes
32
- # @option options [Logger] :logger (::Logger.new($stdout)) which logger to use when OAUTH_DEBUG is enabled
33
- # @option options [Proc] (DEPRECATED) :extract_access_token proc that extracts the access token from the response
37
+ # @option options [String] :authorize_url ('/oauth/authorize') absolute or relative URL path to the Authorization endpoint
38
+ # @option options [String] :revoke_url ('/oauth/revoke') absolute or relative URL path to the Revoke endpoint
39
+ # @option options [String] :token_url ('/oauth/token') absolute or relative URL path to the Token endpoint
40
+ # @option options [Symbol] :token_method (:post) HTTP method to use to request token (:get, :post, :post_with_query_string)
41
+ # @option options [Symbol] :auth_scheme (:basic_auth) the authentication scheme (:basic_auth, :request_body, :tls_client_auth, :private_key_jwt)
42
+ # @option options [Hash] :connection_opts ({}) Hash of connection options to pass to initialize Faraday
43
+ # @option options [Boolean] :raise_errors (true) whether to raise an OAuth2::Error on responses with 400+ status codes
44
+ # @option options [Integer] :max_redirects (5) maximum number of redirects to follow
45
+ # @option options [Logger] :logger (::Logger.new($stdout)) Logger instance for HTTP request/response output; requires OAUTH_DEBUG to be true
46
+ # @option options [Class] :access_token_class (AccessToken) class to use for access tokens; you can subclass OAuth2::AccessToken, @version 2.0+
47
+ # @option options [Hash] :ssl SSL options for Faraday
48
+ #
34
49
  # @yield [builder] The Faraday connection builder
35
50
  def initialize(client_id, client_secret, options = {}, &block)
36
51
  opts = options.dup
@@ -38,28 +53,35 @@ module OAuth2
38
53
  @secret = client_secret
39
54
  @site = opts.delete(:site)
40
55
  ssl = opts.delete(:ssl)
41
- @options = {:authorize_url => 'oauth/authorize',
42
- :token_url => 'oauth/token',
43
- :token_method => :post,
44
- :auth_scheme => :request_body,
45
- :connection_opts => {},
46
- :connection_build => block,
47
- :max_redirects => 5,
48
- :raise_errors => true,
49
- :extract_access_token => DEFAULT_EXTRACT_ACCESS_TOKEN, # DEPRECATED
50
- :logger => ::Logger.new($stdout)}.merge(opts)
56
+ warn("OAuth2::Client#initialize argument `extract_access_token` will be removed in oauth2 v3. Refactor to use `access_token_class`.") if opts[:extract_access_token]
57
+ @options = {
58
+ authorize_url: "oauth/authorize",
59
+ revoke_url: "oauth/revoke",
60
+ token_url: "oauth/token",
61
+ token_method: :post,
62
+ auth_scheme: :basic_auth,
63
+ connection_opts: {},
64
+ connection_build: block,
65
+ max_redirects: 5,
66
+ raise_errors: true,
67
+ logger: ::Logger.new($stdout),
68
+ access_token_class: AccessToken,
69
+ }.merge(opts)
51
70
  @options[:connection_opts][:ssl] = ssl if ssl
52
71
  end
53
72
 
54
73
  # Set the site host
55
74
  #
56
- # @param value [String] the OAuth2 provider site host
75
+ # @param [String] value the OAuth2 provider site host
76
+ # @return [String] the site host value
57
77
  def site=(value)
58
78
  @connection = nil
59
79
  @site = value
60
80
  end
61
81
 
62
82
  # The Faraday connection object
83
+ #
84
+ # @return [Faraday::Connection] the initialized Faraday connection
63
85
  def connection
64
86
  @connection ||=
65
87
  Faraday.new(site, options[:connection_opts]) do |builder|
@@ -67,8 +89,8 @@ module OAuth2
67
89
  if options[:connection_build]
68
90
  options[:connection_build].call(builder)
69
91
  else
70
- builder.request :url_encoded # form-encode POST params
71
- builder.adapter Faraday.default_adapter # make requests with Net::HTTP
92
+ builder.request(:url_encoded) # form-encode POST params
93
+ builder.adapter(Faraday.default_adapter) # make requests with Net::HTTP
72
94
  end
73
95
  end
74
96
  end
@@ -76,6 +98,7 @@ module OAuth2
76
98
  # The authorize endpoint URL of the OAuth2 provider
77
99
  #
78
100
  # @param [Hash] params additional query parameters
101
+ # @return [String] the constructed authorize URL
79
102
  def authorize_url(params = {})
80
103
  params = (params || {}).merge(redirection_params)
81
104
  connection.build_url(options[:authorize_url], params).to_s
@@ -83,118 +106,172 @@ module OAuth2
83
106
 
84
107
  # The token endpoint URL of the OAuth2 provider
85
108
  #
86
- # @param [Hash] params additional query parameters
109
+ # @param [Hash, nil] params additional query parameters
110
+ # @return [String] the constructed token URL
87
111
  def token_url(params = nil)
88
112
  connection.build_url(options[:token_url], params).to_s
89
113
  end
90
114
 
115
+ # The revoke endpoint URL of the OAuth2 provider
116
+ #
117
+ # @param [Hash, nil] params additional query parameters
118
+ # @return [String] the constructed revoke URL
119
+ def revoke_url(params = nil)
120
+ connection.build_url(options[:revoke_url], params).to_s
121
+ end
122
+
91
123
  # Makes a request relative to the specified site root.
92
124
  #
93
- # @param [Symbol] verb one of :get, :post, :put, :delete
125
+ # Updated HTTP 1.1 specification (IETF RFC 7231) relaxed the original constraint (IETF RFC 2616),
126
+ # allowing the use of relative URLs in Location headers.
127
+ #
128
+ # @see https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.2
129
+ #
130
+ # @param [Symbol] verb one of [:get, :post, :put, :delete]
94
131
  # @param [String] url URL path of request
95
- # @param [Hash] opts the options to make the request with
96
- # @option opts [Hash] :params additional query parameters for the URL of the request
97
- # @option opts [Hash, String] :body the body of the request
98
- # @option opts [Hash] :headers http request headers
99
- # @option opts [Boolean] :raise_errors whether or not to raise an OAuth2::Error on 400+ status
100
- # code response for this request. Will default to client option
101
- # @option opts [Symbol] :parse @see Response::initialize
102
- # @yield [req] The Faraday request
103
- def request(verb, url, opts = {}) # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/AbcSize
104
- url = connection.build_url(url).to_s
105
-
106
- begin
107
- response = connection.run_request(verb, url, opts[:body], opts[:headers]) do |req|
108
- req.params.update(opts[:params]) if opts[:params]
109
- yield(req) if block_given?
110
- end
111
- rescue Faraday::ConnectionFailed => e
112
- raise ConnectionError, e
113
- end
114
-
115
- response = Response.new(response, :parse => opts[:parse])
132
+ # @param [Hash] req_opts the options to make the request with
133
+ # @option req_opts [Hash] :params additional query parameters for the URL of the request
134
+ # @option req_opts [Hash, String] :body the body of the request
135
+ # @option req_opts [Hash] :headers http request headers
136
+ # @option req_opts [Boolean] :raise_errors whether to raise an OAuth2::Error on 400+ status
137
+ # code response for this request. Overrides the client instance setting.
138
+ # @option req_opts [Symbol] :parse @see Response::initialize
139
+ # @option req_opts [Boolean] :snaky (true) @see Response::initialize
140
+ #
141
+ # @yield [req] The block is passed the request being made, allowing customization
142
+ # @yieldparam [Faraday::Request] req The request object that can be modified
143
+ # @see Faraday::Connection#run_request
144
+ #
145
+ # @return [OAuth2::Response] the response from the request
146
+ def request(verb, url, req_opts = {}, &block)
147
+ response = execute_request(verb, url, req_opts, &block)
148
+ status = response.status
116
149
 
117
- case response.status
150
+ case status
118
151
  when 301, 302, 303, 307
119
- opts[:redirect_count] ||= 0
120
- opts[:redirect_count] += 1
121
- return response if opts[:redirect_count] > options[:max_redirects]
152
+ req_opts[:redirect_count] ||= 0
153
+ req_opts[:redirect_count] += 1
154
+ return response if req_opts[:redirect_count] > options[:max_redirects]
122
155
 
123
- if response.status == 303
156
+ if status == 303
124
157
  verb = :get
125
- opts.delete(:body)
158
+ req_opts.delete(:body)
126
159
  end
127
- location = response.headers['location']
160
+ location = response.headers["location"]
128
161
  if location
129
- request(verb, location, opts)
162
+ full_location = response.response.env.url.merge(location)
163
+ request(verb, full_location, req_opts)
130
164
  else
131
165
  error = Error.new(response)
132
- raise(error, "Got #{response.status} status code, but no Location header was present")
166
+ raise(error, "Got #{status} status code, but no Location header was present")
133
167
  end
134
168
  when 200..299, 300..399
135
- # on non-redirecting 3xx statuses, just return the response
169
+ # on non-redirecting 3xx statuses, return the response
136
170
  response
137
171
  when 400..599
138
- error = Error.new(response)
139
- raise(error) if opts.fetch(:raise_errors, options[:raise_errors])
172
+ if req_opts.fetch(:raise_errors, options[:raise_errors])
173
+ error = Error.new(response)
174
+ raise(error)
175
+ end
140
176
 
141
- response.error = error
142
177
  response
143
178
  else
144
179
  error = Error.new(response)
145
- raise(error, "Unhandled status code value of #{response.status}")
180
+ raise(error, "Unhandled status code value of #{status}")
146
181
  end
147
182
  end
148
183
 
149
- # Initializes an AccessToken by making a request to the token endpoint
184
+ # Retrieves an access token from the token endpoint using the specified parameters
150
185
  #
151
- # @param params [Hash] a Hash of params for the token endpoint
152
- # @param access_token_opts [Hash] access token options, to pass to the AccessToken object
153
- # @param access_token_class [Class] class of access token for easier subclassing OAuth2::AccessToken
154
- # @return [AccessToken] the initialized AccessToken
155
- def get_token(params, access_token_opts = {}, extract_access_token = options[:extract_access_token]) # # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity Metrics/AbcSize, Metrics/MethodLength
156
- params = params.map do |key, value|
157
- if RESERVED_PARAM_KEYS.include?(key)
158
- [key.to_sym, value]
159
- else
160
- [key, value]
161
- end
162
- end
163
- params = Hash[params]
164
-
165
- params = authenticator.apply(params)
166
- opts = {:raise_errors => options[:raise_errors], :parse => params.delete(:parse)}
167
- headers = params.delete(:headers) || {}
168
- if options[:token_method] == :post
169
- opts[:body] = params
170
- opts[:headers] = {'Content-Type' => 'application/x-www-form-urlencoded'}
186
+ # @param [Hash] params a Hash of params for the token endpoint
187
+ # * params can include a 'headers' key with a Hash of request headers
188
+ # * params can include a 'parse' key with the Symbol name of response parsing strategy (default: :automatic)
189
+ # * params can include a 'snaky' key to control snake_case conversion (default: false)
190
+ # @param [Hash] access_token_opts options that will be passed to the AccessToken initialization
191
+ # @param [Proc] extract_access_token (deprecated) a proc that can extract the access token from the response
192
+ #
193
+ # @yield [opts] The block is passed the options being used to make the request
194
+ # @yieldparam [Hash] opts options being passed to the http library
195
+ #
196
+ # @return [AccessToken, nil] the initialized AccessToken instance, or nil if token extraction fails
197
+ # and raise_errors is false
198
+ #
199
+ # @note The extract_access_token parameter is deprecated and will be removed in oauth2 v3.
200
+ # Use access_token_class on initialization instead.
201
+ #
202
+ # @example
203
+ # client.get_token(
204
+ # 'grant_type' => 'authorization_code',
205
+ # 'code' => 'auth_code_value',
206
+ # 'headers' => {'Authorization' => 'Basic ...'}
207
+ # )
208
+ def get_token(params, access_token_opts = {}, extract_access_token = nil, &block)
209
+ warn("OAuth2::Client#get_token argument `extract_access_token` will be removed in oauth2 v3. Refactor to use `access_token_class` on #initialize.") if extract_access_token
210
+ extract_access_token ||= options[:extract_access_token]
211
+ req_opts = params_to_req_opts(params)
212
+ response = request(http_method, token_url, req_opts, &block)
213
+
214
+ # In v1.4.x, the deprecated extract_access_token option retrieves the token from the response.
215
+ # We preserve this behavior here, but a custom access_token_class that implements #from_hash
216
+ # should be used instead.
217
+ if extract_access_token
218
+ parse_response_legacy(response, access_token_opts, extract_access_token)
171
219
  else
172
- opts[:params] = params
173
- opts[:headers] = {}
174
- end
175
- opts[:headers] = opts[:headers].merge(headers)
176
- http_method = options[:token_method]
177
- response = request(http_method, token_url, opts)
178
-
179
- access_token = begin
180
- build_access_token(response, access_token_opts, extract_access_token)
181
- rescue StandardError
182
- nil
220
+ parse_response(response, access_token_opts)
183
221
  end
222
+ end
184
223
 
185
- response_contains_token = access_token || (
186
- response.parsed.is_a?(Hash) &&
187
- (response.parsed['access_token'] || response.parsed['id_token'])
188
- )
224
+ # Makes a request to revoke a token at the authorization server
225
+ #
226
+ # @param [String] token The token to be revoked
227
+ # @param [String, nil] token_type_hint A hint about the type of the token being revoked (e.g., 'access_token' or 'refresh_token')
228
+ # @param [Hash] params additional parameters for the token revocation
229
+ # @option params [Symbol] :parse (:automatic) parsing strategy for the response
230
+ # @option params [Boolean] :snaky (true) whether to convert response keys to snake_case
231
+ # @option params [Symbol] :token_method (:post_with_query_string) overrides OAuth2::Client#options[:token_method]
232
+ # @option params [Hash] :headers Additional request headers
233
+ #
234
+ # @yield [req] The block is passed the request being made, allowing customization
235
+ # @yieldparam [Faraday::Request] req The request object that can be modified
236
+ #
237
+ # @return [OAuth2::Response] OAuth2::Response instance
238
+ #
239
+ # @api public
240
+ #
241
+ # @note If the token passed to the request
242
+ # is an access token, the server MAY revoke the respective refresh
243
+ # token as well.
244
+ # @note If the token passed to the request
245
+ # is a refresh token and the authorization server supports the
246
+ # revocation of access tokens, then the authorization server SHOULD
247
+ # also invalidate all access tokens based on the same authorization
248
+ # grant
249
+ # @note If the server responds with HTTP status code 503, your code must
250
+ # assume the token still exists and may retry after a reasonable delay.
251
+ # The server may include a "Retry-After" header in the response to
252
+ # indicate how long the service is expected to be unavailable to the
253
+ # requesting client.
254
+ #
255
+ # @see https://datatracker.ietf.org/doc/html/rfc7009
256
+ # @see https://datatracker.ietf.org/doc/html/rfc7009#section-2.1
257
+ def revoke_token(token, token_type_hint = nil, params = {}, &block)
258
+ params[:token_method] ||= :post_with_query_string
259
+ params[:token] = token
260
+ params[:token_type_hint] = token_type_hint if token_type_hint
189
261
 
190
- if options[:raise_errors] && !response_contains_token
191
- error = Error.new(response)
192
- raise(error)
193
- elsif !response_contains_token
194
- return nil
195
- end
262
+ req_opts = params_to_req_opts(params)
196
263
 
197
- access_token
264
+ request(http_method, revoke_url, req_opts, &block)
265
+ end
266
+
267
+ # The HTTP Method of the request
268
+ #
269
+ # @return [Symbol] HTTP verb, one of [:get, :post, :put, :delete]
270
+ def http_method
271
+ http_meth = options[:token_method].to_sym
272
+ return :post if http_meth == :post_with_query_string
273
+
274
+ http_meth
198
275
  end
199
276
 
200
277
  # The Authorization Code strategy
@@ -225,6 +302,15 @@ module OAuth2
225
302
  @client_credentials ||= OAuth2::Strategy::ClientCredentials.new(self)
226
303
  end
227
304
 
305
+ # The Assertion strategy
306
+ #
307
+ # This allows for assertion-based authentication where an identity provider
308
+ # asserts the identity of the user or client application seeking access.
309
+ #
310
+ # @see http://datatracker.ietf.org/doc/html/rfc7521
311
+ # @see http://datatracker.ietf.org/doc/html/draft-ietf-oauth-assertions-01#section-4.1
312
+ #
313
+ # @return [OAuth2::Strategy::Assertion] the initialized Assertion strategy
228
314
  def assertion
229
315
  @assertion ||= OAuth2::Strategy::Assertion.new(self)
230
316
  end
@@ -235,7 +321,10 @@ module OAuth2
235
321
  # requesting authorization. If it is provided at authorization time it MUST
236
322
  # also be provided with the token exchange request.
237
323
  #
238
- # Providing the :redirect_uri to the OAuth2::Client instantiation will take
324
+ # OAuth 2.1 note: Authorization Servers must compare redirect URIs using exact string matching.
325
+ # This client simply forwards the configured redirect_uri; the exact-match validation happens server-side.
326
+ #
327
+ # Providing :redirect_uri to the OAuth2::Client instantiation will take
239
328
  # care of managing this.
240
329
  #
241
330
  # @api semipublic
@@ -244,21 +333,137 @@ module OAuth2
244
333
  # @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
245
334
  # @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.1
246
335
  # @see https://datatracker.ietf.org/doc/html/rfc6749#section-10.6
336
+ # @see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13
337
+ #
247
338
  # @return [Hash] the params to add to a request or URL
248
339
  def redirection_params
249
340
  if options[:redirect_uri]
250
- {'redirect_uri' => options[:redirect_uri]}
341
+ {"redirect_uri" => options[:redirect_uri]}
251
342
  else
252
343
  {}
253
344
  end
254
345
  end
255
346
 
256
- DEFAULT_EXTRACT_ACCESS_TOKEN = proc do |client, hash|
257
- token = hash.delete('access_token') || hash.delete(:access_token)
258
- token && AccessToken.new(client, token, hash)
347
+ private
348
+
349
+ # Processes request parameters and transforms them into request options
350
+ #
351
+ # @param [Hash] params the request parameters to process
352
+ # @option params [Symbol] :parse (:automatic) parsing strategy for the response
353
+ # @option params [Boolean] :snaky (true) whether to convert response keys to snake_case
354
+ # @option params [Class] :snaky_hash_klass (SnakyHash::StringKeyed) class to use for snake_case hash conversion
355
+ # @option params [Symbol] :token_method (:post) HTTP method to use for token request
356
+ # @option params [Hash] :headers Additional HTTP headers for the request
357
+ #
358
+ # @return [Hash] the processed request options
359
+ #
360
+ # @api private
361
+ def params_to_req_opts(params)
362
+ parse, snaky, snaky_hash_klass, token_method, params, headers = parse_snaky_params_headers(params)
363
+ req_opts = {
364
+ raise_errors: options[:raise_errors],
365
+ token_method: token_method || options[:token_method],
366
+ parse: parse,
367
+ snaky: snaky,
368
+ snaky_hash_klass: snaky_hash_klass,
369
+ }
370
+ if req_opts[:token_method] == :post
371
+ # NOTE: If proliferation of request types continues, we should implement a parser solution for Request,
372
+ # just like we have with Response.
373
+ req_opts[:body] = if headers["Content-Type"] == "application/json"
374
+ params.to_json
375
+ else
376
+ params
377
+ end
378
+
379
+ req_opts[:headers] = {"Content-Type" => "application/x-www-form-urlencoded"}
380
+ else
381
+ req_opts[:params] = params
382
+ req_opts[:headers] = {}
383
+ end
384
+ req_opts[:headers].merge!(headers)
385
+ req_opts
386
+ end
387
+
388
+ # Processes and transforms parameters for OAuth requests
389
+ #
390
+ # @param [Hash] params the input parameters to process
391
+ # @option params [Symbol] :parse (:automatic) parsing strategy for the response
392
+ # @option params [Boolean] :snaky (true) whether to convert response keys to snake_case
393
+ # @option params [Class] :snaky_hash_klass (SnakyHash::StringKeyed) class to use for snake_case hash conversion
394
+ # @option params [Symbol] :token_method overrides the default token method for this request
395
+ # @option params [Hash] :headers HTTP headers for the request
396
+ #
397
+ # @return [Array<(Symbol, Boolean, Class, Symbol, Hash, Hash)>] Returns an array containing:
398
+ # - parse strategy (Symbol)
399
+ # - snaky flag for response key transformation (Boolean)
400
+ # - hash class for snake_case conversion (Class)
401
+ # - token method override (Symbol, nil)
402
+ # - processed parameters (Hash)
403
+ # - HTTP headers (Hash)
404
+ #
405
+ # @api private
406
+ def parse_snaky_params_headers(params)
407
+ params = params.map do |key, value|
408
+ if RESERVED_PARAM_KEYS.include?(key)
409
+ [key.to_sym, value]
410
+ else
411
+ [key, value]
412
+ end
413
+ end.to_h
414
+ parse = params.key?(:parse) ? params.delete(:parse) : Response::DEFAULT_OPTIONS[:parse]
415
+ snaky = params.key?(:snaky) ? params.delete(:snaky) : Response::DEFAULT_OPTIONS[:snaky]
416
+ snaky_hash_klass = params.key?(:snaky_hash_klass) ? params.delete(:snaky_hash_klass) : Response::DEFAULT_OPTIONS[:snaky_hash_klass]
417
+ token_method = params.delete(:token_method) if params.key?(:token_method)
418
+ params = authenticator.apply(params)
419
+ # authenticator may add :headers, and we separate them from params here
420
+ headers = params.delete(:headers) || {}
421
+ [parse, snaky, snaky_hash_klass, token_method, params, headers]
259
422
  end
260
423
 
261
- private
424
+ # Executes an HTTP request with error handling and response processing
425
+ #
426
+ # @param [Symbol] verb the HTTP method to use (:get, :post, :put, :delete)
427
+ # @param [String] url the URL for the request
428
+ # @param [Hash] opts the request options
429
+ # @option opts [Hash] :body the request body
430
+ # @option opts [Hash] :headers the request headers
431
+ # @option opts [Hash] :params the query parameters to append to the URL
432
+ # @option opts [Symbol, nil] :parse (:automatic) parsing strategy for the response
433
+ # @option opts [Boolean] :snaky (true) whether to convert response keys to snake_case
434
+ #
435
+ # @yield [req] gives access to the request object before sending
436
+ # @yieldparam [Faraday::Request] req the request object that can be modified
437
+ #
438
+ # @return [OAuth2::Response] the response wrapped in an OAuth2::Response object
439
+ #
440
+ # @raise [OAuth2::ConnectionError] when there's a network error
441
+ # @raise [OAuth2::TimeoutError] when the request times out
442
+ #
443
+ # @api private
444
+ def execute_request(verb, url, opts = {})
445
+ url = connection.build_url(url).to_s
446
+ # See: Hash#partition https://bugs.ruby-lang.org/issues/16252
447
+ req_opts, oauth_opts = opts.
448
+ partition { |k, _v| RESERVED_REQ_KEYS.include?(k.to_s) }.
449
+ map { |p| Hash[p] }
450
+
451
+ begin
452
+ response = connection.run_request(verb, url, req_opts[:body], req_opts[:headers]) do |req|
453
+ req.params.update(req_opts[:params]) if req_opts[:params]
454
+ yield(req) if block_given?
455
+ end
456
+ rescue Faraday::ConnectionFailed => e
457
+ raise ConnectionError, e
458
+ rescue Faraday::TimeoutError => e
459
+ raise TimeoutError, e
460
+ end
461
+
462
+ parse = oauth_opts.key?(:parse) ? oauth_opts.delete(:parse) : Response::DEFAULT_OPTIONS[:parse]
463
+ snaky = oauth_opts.key?(:snaky) ? oauth_opts.delete(:snaky) : Response::DEFAULT_OPTIONS[:snaky]
464
+
465
+ Response.new(response, parse: parse, snaky: snaky)
466
+ end
262
467
 
263
468
  # Returns the authenticator object
264
469
  #
@@ -267,26 +472,98 @@ module OAuth2
267
472
  Authenticator.new(id, secret, options[:auth_scheme])
268
473
  end
269
474
 
270
- # Builds the access token from the response of the HTTP call
475
+ # Parses the OAuth response and builds an access token using legacy extraction method
271
476
  #
272
- # @return [AccessToken] the initialized AccessToken
273
- def build_access_token(response, access_token_opts, extract_access_token)
274
- parsed_response = response.parsed.dup
275
- return unless parsed_response.is_a?(Hash)
477
+ # @deprecated Use {#parse_response} instead
478
+ #
479
+ # @param [OAuth2::Response] response the OAuth2::Response from the token endpoint
480
+ # @param [Hash] access_token_opts options to pass to the AccessToken initialization
481
+ # @param [Proc] extract_access_token proc to extract the access token from response
482
+ #
483
+ # @return [AccessToken, nil] the initialized AccessToken if successful, nil if extraction fails
484
+ # and raise_errors option is false
485
+ #
486
+ # @raise [OAuth2::Error] if response indicates an error and raise_errors option is true
487
+ #
488
+ # @api private
489
+ def parse_response_legacy(response, access_token_opts, extract_access_token)
490
+ access_token = build_access_token_legacy(response, access_token_opts, extract_access_token)
276
491
 
277
- hash = parsed_response.merge(access_token_opts)
492
+ return access_token if access_token
278
493
 
279
- # Provide backwards compatibility for old AccessToken.form_hash pattern
280
- # Will be deprecated in 2.x
281
- if extract_access_token.is_a?(Class) && extract_access_token.respond_to?(:from_hash)
282
- extract_access_token.from_hash(self, hash)
283
- else
284
- extract_access_token.call(self, hash)
494
+ if options[:raise_errors]
495
+ error = Error.new(response)
496
+ raise(error)
285
497
  end
498
+
499
+ nil
500
+ end
501
+
502
+ # Parses the OAuth response and builds an access token using the configured access token class
503
+ #
504
+ # @param [OAuth2::Response] response the OAuth2::Response from the token endpoint
505
+ # @param [Hash] access_token_opts options to pass to the AccessToken initialization
506
+ #
507
+ # @return [AccessToken] the initialized AccessToken instance
508
+ #
509
+ # @raise [OAuth2::Error] if the response is empty/invalid and the raise_errors option is true
510
+ #
511
+ # @api private
512
+ def parse_response(response, access_token_opts)
513
+ access_token_class = options[:access_token_class]
514
+ data = response.parsed
515
+
516
+ unless data.is_a?(Hash) && !data.empty?
517
+ return unless options[:raise_errors]
518
+
519
+ error = Error.new(response)
520
+ raise(error)
521
+ end
522
+
523
+ build_access_token(response, access_token_opts, access_token_class)
524
+ end
525
+
526
+ # Creates an access token instance from response data using the specified token class
527
+ #
528
+ # @param [OAuth2::Response] response the OAuth2::Response from the token endpoint
529
+ # @param [Hash] access_token_opts additional options to pass to the AccessToken initialization
530
+ # @param [Class] access_token_class the class that should be used to create access token instances
531
+ #
532
+ # @return [AccessToken] an initialized AccessToken instance with response data
533
+ #
534
+ # @note If the access token class responds to response=, the full response object will be set
535
+ #
536
+ # @api private
537
+ def build_access_token(response, access_token_opts, access_token_class)
538
+ access_token_class.from_hash(self, response.parsed.merge(access_token_opts)).tap do |access_token|
539
+ access_token.response = response if access_token.respond_to?(:response=)
540
+ end
541
+ end
542
+
543
+ # Builds an access token using a legacy extraction proc
544
+ #
545
+ # @deprecated Use {#build_access_token} instead
546
+ #
547
+ # @param [OAuth2::Response] response the OAuth2::Response from the token endpoint
548
+ # @param [Hash] access_token_opts additional options to pass to the access token extraction
549
+ # @param [Proc] extract_access_token a proc that takes client and token hash as arguments
550
+ # and returns an access token instance
551
+ #
552
+ # @return [AccessToken, nil] the access token instance if extraction succeeds,
553
+ # nil if any error occurs during extraction
554
+ #
555
+ # @api private
556
+ def build_access_token_legacy(response, access_token_opts, extract_access_token)
557
+ extract_access_token.call(self, response.parsed.merge(access_token_opts))
558
+ rescue
559
+ # An error will be raised by the called if nil is returned and options[:raise_errors] is truthy, so this rescue is but temporary.
560
+ # Unfortunately, it does hide the real error, but this is deprecated legacy code,
561
+ # and this was effectively the long-standing pre-existing behavior, so there is little point in changing it.
562
+ nil
286
563
  end
287
564
 
288
565
  def oauth_debug_logging(builder)
289
- builder.response :logger, options[:logger], :bodies => true if ENV['OAUTH_DEBUG'] == 'true'
566
+ builder.response(:logger, options[:logger], bodies: true) if OAuth2::OAUTH_DEBUG
290
567
  end
291
568
  end
292
569
  end