oauth2 2.0.9 → 2.0.25

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