oauth2 1.4.7 → 2.0.20

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 (58) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +811 -76
  4. data/CITATION.cff +20 -0
  5. data/CODE_OF_CONDUCT.md +24 -23
  6. data/CONTRIBUTING.md +221 -0
  7. data/FUNDING.md +74 -0
  8. data/IRP.md +107 -0
  9. data/{LICENSE → LICENSE.txt} +2 -2
  10. data/OIDC.md +167 -0
  11. data/README.md +1468 -166
  12. data/REEK +2 -0
  13. data/RUBOCOP.md +71 -0
  14. data/SECURITY.md +24 -0
  15. data/THREAT_MODEL.md +94 -0
  16. data/lib/oauth2/access_token.rb +276 -40
  17. data/lib/oauth2/auth_sanitizer.rb +36 -0
  18. data/lib/oauth2/authenticator.rb +51 -10
  19. data/lib/oauth2/client.rb +444 -124
  20. data/lib/oauth2/error.rb +63 -24
  21. data/lib/oauth2/filtered_attributes.rb +10 -0
  22. data/lib/oauth2/response.rb +138 -43
  23. data/lib/oauth2/strategy/assertion.rb +71 -41
  24. data/lib/oauth2/strategy/auth_code.rb +28 -5
  25. data/lib/oauth2/strategy/base.rb +2 -0
  26. data/lib/oauth2/strategy/client_credentials.rb +6 -4
  27. data/lib/oauth2/strategy/implicit.rb +20 -3
  28. data/lib/oauth2/strategy/password.rb +17 -5
  29. data/lib/oauth2/version.rb +2 -59
  30. data/lib/oauth2.rb +103 -12
  31. data/sig/oauth2/access_token.rbs +25 -0
  32. data/sig/oauth2/authenticator.rbs +22 -0
  33. data/sig/oauth2/client.rbs +52 -0
  34. data/sig/oauth2/error.rbs +8 -0
  35. data/sig/oauth2/filtered_attributes.rbs +11 -0
  36. data/sig/oauth2/response.rbs +18 -0
  37. data/sig/oauth2/sanitized_logger.rbs +32 -0
  38. data/sig/oauth2/strategy.rbs +34 -0
  39. data/sig/oauth2/thing_filter.rbs +10 -0
  40. data/sig/oauth2/version.rbs +5 -0
  41. data/sig/oauth2.rbs +9 -0
  42. data.tar.gz.sig +0 -0
  43. metadata +293 -102
  44. metadata.gz.sig +4 -0
  45. data/lib/oauth2/mac_token.rb +0 -130
  46. data/spec/helper.rb +0 -37
  47. data/spec/oauth2/access_token_spec.rb +0 -216
  48. data/spec/oauth2/authenticator_spec.rb +0 -84
  49. data/spec/oauth2/client_spec.rb +0 -506
  50. data/spec/oauth2/mac_token_spec.rb +0 -117
  51. data/spec/oauth2/response_spec.rb +0 -90
  52. data/spec/oauth2/strategy/assertion_spec.rb +0 -58
  53. data/spec/oauth2/strategy/auth_code_spec.rb +0 -107
  54. data/spec/oauth2/strategy/base_spec.rb +0 -5
  55. data/spec/oauth2/strategy/client_credentials_spec.rb +0 -69
  56. data/spec/oauth2/strategy/implicit_spec.rb +0 -26
  57. data/spec/oauth2/strategy/password_spec.rb +0 -55
  58. data/spec/oauth2/version_spec.rb +0 -23
data/lib/oauth2/client.rb CHANGED
@@ -1,32 +1,51 @@
1
- require 'faraday'
2
- require 'logger'
1
+ # frozen_string_literal: true
2
+
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:
3
14
 
4
15
  module OAuth2
16
+ ConnectionError = Class.new(Faraday::ConnectionFailed)
17
+ TimeoutError = Class.new(Faraday::TimeoutError)
18
+
5
19
  # The OAuth2::Client class
6
20
  class Client # rubocop:disable Metrics/ClassLength
7
- 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
8
25
 
9
26
  attr_reader :id, :secret, :site
10
27
  attr_accessor :options
11
28
  attr_writer :connection
29
+ filtered_attributes :secret
12
30
 
13
- # Instantiate a new OAuth 2.0 client using the
14
- # Client ID and Client Secret registered to your
15
- # application.
31
+ # Initializes a new OAuth2::Client instance using the Client ID and Client Secret registered to your application.
16
32
  #
17
33
  # @param [String] client_id the client_id value
18
34
  # @param [String] client_secret the client_secret value
19
- # @param [Hash] opts the options to create the client with
20
- # @option opts [String] :site the OAuth2 provider site host
21
- # @option opts [String] :redirect_uri the absolute URI to the Redirection Endpoint for use in authorization grants and token exchange
22
- # @option opts [String] :authorize_url ('/oauth/authorize') absolute or relative URL path to the Authorization endpoint
23
- # @option opts [String] :token_url ('/oauth/token') absolute or relative URL path to the Token endpoint
24
- # @option opts [Symbol] :token_method (:post) HTTP method to use to request token (:get or :post)
25
- # @option opts [Symbol] :auth_scheme (:basic_auth) HTTP method to use to authorize request (:basic_auth or :request_body)
26
- # @option opts [Hash] :connection_opts ({}) Hash of connection options to pass to initialize Faraday with
27
- # @option opts [FixNum] :max_redirects (5) maximum number of redirects to follow
28
- # @option opts [Boolean] :raise_errors (true) whether or not to raise an OAuth2::Error on responses with 400+ status codes
29
- # @option opts [Proc] :extract_access_token proc that extracts the access token from the response
35
+ # @param [Hash] options the options to configure the client
36
+ # @option options [String] :site the OAuth2 provider site host
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. When debug logging is enabled, sensitive values are filtered using {OAuth2::AUTH_SANITIZER::SanitizedLogger} initialized from `OAuth2.config[:filtered_label]` and the key names in `OAuth2.config[:filtered_debug_keys]`.
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
+ #
30
49
  # @yield [builder] The Faraday connection builder
31
50
  def initialize(client_id, client_secret, options = {}, &block)
32
51
  opts = options.dup
@@ -34,45 +53,52 @@ module OAuth2
34
53
  @secret = client_secret
35
54
  @site = opts.delete(:site)
36
55
  ssl = opts.delete(:ssl)
37
-
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]
38
57
  @options = {
39
- :authorize_url => '/oauth/authorize',
40
- :token_url => '/oauth/token',
41
- :token_method => :post,
42
- :auth_scheme => :request_body,
43
- :connection_opts => {},
44
- :connection_build => block,
45
- :max_redirects => 5,
46
- :raise_errors => true,
47
- :extract_access_token => DEFAULT_EXTRACT_ACCESS_TOKEN,
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,
48
69
  }.merge(opts)
49
70
  @options[:connection_opts][:ssl] = ssl if ssl
50
71
  end
51
72
 
52
73
  # Set the site host
53
74
  #
54
- # @param [String] the OAuth2 provider site host
75
+ # @param [String] value the OAuth2 provider site host
76
+ # @return [String] the site host value
55
77
  def site=(value)
56
78
  @connection = nil
57
79
  @site = value
58
80
  end
59
81
 
60
82
  # The Faraday connection object
83
+ #
84
+ # @return [Faraday::Connection] the initialized Faraday connection
61
85
  def connection
62
- @connection ||= begin
63
- conn = Faraday.new(site, options[:connection_opts])
64
- if options[:connection_build]
65
- conn.build do |b|
66
- options[:connection_build].call(b)
86
+ @connection ||=
87
+ Faraday.new(site, options[:connection_opts]) do |builder|
88
+ oauth_debug_logging(builder)
89
+ if options[:connection_build]
90
+ options[:connection_build].call(builder)
91
+ else
92
+ builder.request(:url_encoded) # form-encode POST params
93
+ builder.adapter(Faraday.default_adapter) # make requests with Net::HTTP
67
94
  end
68
95
  end
69
- conn
70
- end
71
96
  end
72
97
 
73
98
  # The authorize endpoint URL of the OAuth2 provider
74
99
  #
75
100
  # @param [Hash] params additional query parameters
101
+ # @return [String] the constructed authorize URL
76
102
  def authorize_url(params = {})
77
103
  params = (params || {}).merge(redirection_params)
78
104
  connection.build_url(options[:authorize_url], params).to_s
@@ -80,130 +106,211 @@ module OAuth2
80
106
 
81
107
  # The token endpoint URL of the OAuth2 provider
82
108
  #
83
- # @param [Hash] params additional query parameters
109
+ # @param [Hash, nil] params additional query parameters
110
+ # @return [String] the constructed token URL
84
111
  def token_url(params = nil)
85
112
  connection.build_url(options[:token_url], params).to_s
86
113
  end
87
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
+
88
123
  # Makes a request relative to the specified site root.
89
124
  #
90
- # @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]
91
131
  # @param [String] url URL path of request
92
- # @param [Hash] opts the options to make the request with
93
- # @option opts [Hash] :params additional query parameters for the URL of the request
94
- # @option opts [Hash, String] :body the body of the request
95
- # @option opts [Hash] :headers http request headers
96
- # @option opts [Boolean] :raise_errors whether or not to raise an OAuth2::Error on 400+ status
97
- # code response for this request. Will default to client option
98
- # @option opts [Symbol] :parse @see Response::initialize
99
- # @yield [req] The Faraday request
100
- def request(verb, url, opts = {}) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
101
- connection.response :logger, ::Logger.new($stdout) if ENV['OAUTH_DEBUG'] == 'true'
102
-
103
- url = connection.build_url(url).to_s
104
-
105
- response = connection.run_request(verb, url, opts[:body], opts[:headers]) do |req|
106
- req.params.update(opts[:params]) if opts[:params]
107
- yield(req) if block_given?
108
- end
109
- 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
110
149
 
111
- case response.status
150
+ case status
112
151
  when 301, 302, 303, 307
113
- opts[:redirect_count] ||= 0
114
- opts[:redirect_count] += 1
115
- 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]
116
155
 
117
- if response.status == 303
156
+ if status == 303
118
157
  verb = :get
119
- opts.delete(:body)
158
+ req_opts.delete(:body)
159
+ end
160
+ location = response.headers["location"]
161
+ if location
162
+ full_location = response.response.env.url.merge(location)
163
+ request(verb, full_location, req_opts)
164
+ else
165
+ error = Error.new(response)
166
+ raise(error, "Got #{status} status code, but no Location header was present")
120
167
  end
121
- request(verb, response.headers['location'], opts)
122
168
  when 200..299, 300..399
123
- # on non-redirecting 3xx statuses, just return the response
169
+ # on non-redirecting 3xx statuses, return the response
124
170
  response
125
171
  when 400..599
126
- error = Error.new(response)
127
- 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
128
176
 
129
- response.error = error
130
177
  response
131
178
  else
132
179
  error = Error.new(response)
133
- raise(error, "Unhandled status code value of #{response.status}")
180
+ raise(error, "Unhandled status code value of #{status}")
134
181
  end
135
182
  end
136
183
 
137
- # Initializes an AccessToken by making a request to the token endpoint
184
+ # Retrieves an access token from the token endpoint using the specified parameters
138
185
  #
139
186
  # @param [Hash] params a Hash of params for the token endpoint
140
- # @param [Hash] access token options, to pass to the AccessToken object
141
- # @param [Class] class of access token for easier subclassing OAuth2::AccessToken
142
- # @return [AccessToken] the initialized AccessToken
143
- def get_token(params, access_token_opts = {}, extract_access_token = options[:extract_access_token]) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
144
- params = params.map do |key, value|
145
- if RESERVED_PARAM_KEYS.include?(key)
146
- [key.to_sym, value]
147
- else
148
- [key, value]
149
- end
150
- end
151
- params = Hash[params]
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)
152
213
 
153
- params = Authenticator.new(id, secret, options[:auth_scheme]).apply(params)
154
- opts = {:raise_errors => options[:raise_errors], :parse => params.delete(:parse)}
155
- headers = params.delete(:headers) || {}
156
- if options[:token_method] == :post
157
- opts[:body] = params
158
- opts[:headers] = {'Content-Type' => 'application/x-www-form-urlencoded'}
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)
159
219
  else
160
- opts[:params] = params
161
- opts[:headers] = {}
220
+ parse_response(response, access_token_opts)
162
221
  end
163
- opts[:headers].merge!(headers)
164
- response = request(options[:token_method], token_url, opts)
222
+ end
165
223
 
166
- access_token = begin
167
- build_access_token(response, access_token_opts, extract_access_token)
168
- rescue StandardError
169
- nil
170
- end
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
171
261
 
172
- if options[:raise_errors] && !access_token
173
- error = Error.new(response)
174
- raise(error)
175
- end
176
- access_token
262
+ req_opts = params_to_req_opts(params)
263
+
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
177
275
  end
178
276
 
179
277
  # The Authorization Code strategy
180
278
  #
181
- # @see http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.1
279
+ # @see http://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-15#section-4.1
182
280
  def auth_code
183
281
  @auth_code ||= OAuth2::Strategy::AuthCode.new(self)
184
282
  end
185
283
 
186
284
  # The Implicit strategy
187
285
  #
188
- # @see http://tools.ietf.org/html/draft-ietf-oauth-v2-26#section-4.2
286
+ # @see http://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-26#section-4.2
189
287
  def implicit
190
288
  @implicit ||= OAuth2::Strategy::Implicit.new(self)
191
289
  end
192
290
 
193
291
  # The Resource Owner Password Credentials strategy
194
292
  #
195
- # @see http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.3
293
+ # @see http://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-15#section-4.3
196
294
  def password
197
295
  @password ||= OAuth2::Strategy::Password.new(self)
198
296
  end
199
297
 
200
298
  # The Client Credentials strategy
201
299
  #
202
- # @see http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.4
300
+ # @see http://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-15#section-4.4
203
301
  def client_credentials
204
302
  @client_credentials ||= OAuth2::Strategy::ClientCredentials.new(self)
205
303
  end
206
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
207
314
  def assertion
208
315
  @assertion ||= OAuth2::Strategy::Assertion.new(self)
209
316
  end
@@ -214,44 +321,257 @@ module OAuth2
214
321
  # requesting authorization. If it is provided at authorization time it MUST
215
322
  # also be provided with the token exchange request.
216
323
  #
217
- # 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
218
328
  # care of managing this.
219
329
  #
220
330
  # @api semipublic
221
331
  #
222
- # @see https://tools.ietf.org/html/rfc6749#section-4.1
223
- # @see https://tools.ietf.org/html/rfc6749#section-4.1.3
224
- # @see https://tools.ietf.org/html/rfc6749#section-4.2.1
225
- # @see https://tools.ietf.org/html/rfc6749#section-10.6
332
+ # @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1
333
+ # @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
334
+ # @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.1
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
+ #
226
338
  # @return [Hash] the params to add to a request or URL
227
339
  def redirection_params
228
340
  if options[:redirect_uri]
229
- {'redirect_uri' => options[:redirect_uri]}
341
+ {"redirect_uri" => options[:redirect_uri]}
230
342
  else
231
343
  {}
232
344
  end
233
345
  end
234
346
 
235
- DEFAULT_EXTRACT_ACCESS_TOKEN = proc do |client, hash|
236
- token = hash.delete('access_token') || hash.delete(:access_token)
237
- 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]
238
422
  end
239
423
 
240
- 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] }
241
450
 
242
- def build_access_token(response, access_token_opts, extract_access_token)
243
- parsed_response = response.parsed.dup
244
- return unless parsed_response.is_a?(Hash)
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
245
461
 
246
- hash = parsed_response.merge(access_token_opts)
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]
247
464
 
248
- # Provide backwards compatibility for old AcessToken.form_hash pattern
249
- # Should be deprecated in 2.x
250
- if extract_access_token.is_a?(Class) && extract_access_token.respond_to?(:from_hash)
251
- extract_access_token.from_hash(self, hash)
252
- else
253
- extract_access_token.call(self, hash)
465
+ Response.new(response, parse: parse, snaky: snaky)
466
+ end
467
+
468
+ # Returns the authenticator object
469
+ #
470
+ # @return [Authenticator] the initialized Authenticator
471
+ def authenticator
472
+ Authenticator.new(id, secret, options[:auth_scheme])
473
+ end
474
+
475
+ # Parses the OAuth response and builds an access token using legacy extraction method
476
+ #
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)
491
+
492
+ return access_token if access_token
493
+
494
+ if options[:raise_errors]
495
+ error = Error.new(response)
496
+ raise(error)
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)
254
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
563
+ end
564
+
565
+ def oauth_debug_logging(builder)
566
+ builder.response(
567
+ :logger,
568
+ OAuth2::AUTH_SANITIZER::SanitizedLogger.new(
569
+ options[:logger],
570
+ filtered_keys: OAuth2.config[:filtered_debug_keys],
571
+ label: OAuth2.config[:filtered_label],
572
+ ),
573
+ bodies: true,
574
+ ) if OAuth2::OAUTH_DEBUG
255
575
  end
256
576
  end
257
577
  end