oauth2 1.4.10 → 2.0.8

Sign up to get free protection for your applications and to get access to all the features.
data/lib/oauth2/client.rb CHANGED
@@ -5,9 +5,11 @@ require 'logger'
5
5
 
6
6
  module OAuth2
7
7
  ConnectionError = Class.new(Faraday::ConnectionFailed)
8
+ TimeoutError = Class.new(Faraday::TimeoutError)
9
+
8
10
  # The OAuth2::Client class
9
11
  class Client # rubocop:disable Metrics/ClassLength
10
- RESERVED_PARAM_KEYS = %w[headers parse].freeze
12
+ RESERVED_PARAM_KEYS = %w[body headers params parse snaky].freeze
11
13
 
12
14
  attr_reader :id, :secret, :site
13
15
  attr_accessor :options
@@ -22,15 +24,16 @@ module OAuth2
22
24
  # @param [Hash] options the options to create the client with
23
25
  # @option options [String] :site the OAuth2 provider site host
24
26
  # @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)
27
+ # @option options [String] :authorize_url ('/oauth/authorize') absolute or relative URL path to the Authorization endpoint
28
+ # @option options [String] :token_url ('/oauth/token') absolute or relative URL path to the Token endpoint
29
+ # @option options [Symbol] :token_method (:post) HTTP method to use to request token (:get, :post, :post_with_query_string)
28
30
  # @option options [Symbol] :auth_scheme (:basic_auth) HTTP method to use to authorize request (:basic_auth or :request_body)
29
31
  # @option options [Hash] :connection_opts ({}) Hash of connection options to pass to initialize Faraday with
30
32
  # @option options [FixNum] :max_redirects (5) maximum number of redirects to follow
31
33
  # @option options [Boolean] :raise_errors (true) whether or not to raise an OAuth2::Error on responses with 400+ status codes
32
34
  # @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
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+
34
37
  # @yield [builder] The Faraday connection builder
35
38
  def initialize(client_id, client_secret, options = {}, &block)
36
39
  opts = options.dup
@@ -38,16 +41,19 @@ module OAuth2
38
41
  @secret = client_secret
39
42
  @site = opts.delete(:site)
40
43
  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)
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]
45
+ @options = {
46
+ authorize_url: 'oauth/authorize',
47
+ token_url: 'oauth/token',
48
+ token_method: :post,
49
+ auth_scheme: :basic_auth,
50
+ connection_opts: {},
51
+ connection_build: block,
52
+ max_redirects: 5,
53
+ raise_errors: true,
54
+ logger: ::Logger.new($stdout),
55
+ access_token_class: AccessToken,
56
+ }.merge(opts)
51
57
  @options[:connection_opts][:ssl] = ssl if ssl
52
58
  end
53
59
 
@@ -89,6 +95,9 @@ module OAuth2
89
95
  end
90
96
 
91
97
  # Makes a request relative to the specified site root.
98
+ # Updated HTTP 1.1 specification (IETF RFC 7231) relaxed the original constraint (IETF RFC 2616),
99
+ # allowing the use of relative URLs in Location headers.
100
+ # @see https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.2
92
101
  #
93
102
  # @param [Symbol] verb one of :get, :post, :put, :delete
94
103
  # @param [String] url URL path of request
@@ -99,20 +108,10 @@ module OAuth2
99
108
  # @option opts [Boolean] :raise_errors whether or not to raise an OAuth2::Error on 400+ status
100
109
  # code response for this request. Will default to client option
101
110
  # @option opts [Symbol] :parse @see Response::initialize
102
- # @yield [req] The Faraday request
103
- def request(verb, url, opts = {}) # rubocop:disable 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])
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)
116
115
 
117
116
  case response.status
118
117
  when 301, 302, 303, 307
@@ -126,7 +125,8 @@ module OAuth2
126
125
  end
127
126
  location = response.headers['location']
128
127
  if location
129
- request(verb, location, opts)
128
+ full_location = response.response.env.url.merge(location)
129
+ request(verb, full_location, opts)
130
130
  else
131
131
  error = Error.new(response)
132
132
  raise(error, "Got #{response.status} status code, but no Location header was present")
@@ -138,7 +138,6 @@ module OAuth2
138
138
  error = Error.new(response)
139
139
  raise(error) if opts.fetch(:raise_errors, options[:raise_errors])
140
140
 
141
- response.error = error
142
141
  response
143
142
  else
144
143
  error = Error.new(response)
@@ -148,53 +147,58 @@ module OAuth2
148
147
 
149
148
  # Initializes an AccessToken by making a request to the token endpoint
150
149
  #
151
- # @param params [Hash] a Hash of params for the token endpoint
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
152
153
  # @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
+ # @param extract_access_token [Proc] proc that extracts the access token from the response (DEPRECATED)
155
+ # @yield [req] @see Faraday::Connection#run_request
154
156
  # @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) || {}
157
+ 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
159
+ 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
+ }
168
167
  if options[:token_method] == :post
169
- opts[:body] = params
170
- opts[:headers] = {'Content-Type' => 'application/x-www-form-urlencoded'}
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'}
171
178
  else
172
- opts[:params] = params
173
- opts[:headers] = {}
179
+ request_opts[:params] = params
180
+ request_opts[:headers] = {}
174
181
  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
182
+ request_opts[:headers].merge!(headers)
183
+ response = request(http_method, token_url, request_opts, &block)
184
+
185
+ # In v1.4.x, the deprecated extract_access_token option retrieves the token from the response.
186
+ # We preserve this behavior here, but a custom access_token_class that implements #from_hash
187
+ # should be used instead.
188
+ if extract_access_token
189
+ parse_response_legacy(response, access_token_opts, extract_access_token)
190
+ else
191
+ parse_response(response, access_token_opts)
183
192
  end
193
+ end
184
194
 
185
- response_contains_token = access_token || (
186
- response.parsed.is_a?(Hash) &&
187
- (response.parsed['access_token'] || response.parsed['id_token'])
188
- )
189
-
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
195
+ # The HTTP Method of the request
196
+ # @return [Symbol] HTTP verb, one of :get, :post, :put, :delete
197
+ def http_method
198
+ http_meth = options[:token_method].to_sym
199
+ return :post if http_meth == :post_with_query_string
196
200
 
197
- access_token
201
+ http_meth
198
202
  end
199
203
 
200
204
  # The Authorization Code strategy
@@ -253,12 +257,43 @@ module OAuth2
253
257
  end
254
258
  end
255
259
 
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)
260
+ private
261
+
262
+ def parse_snaky_params_headers(params)
263
+ params = params.map do |key, value|
264
+ if RESERVED_PARAM_KEYS.include?(key)
265
+ [key.to_sym, value]
266
+ else
267
+ [key, value]
268
+ end
269
+ end.to_h
270
+ parse = params.key?(:parse) ? params.delete(:parse) : Response::DEFAULT_OPTIONS[:parse]
271
+ snaky = params.key?(:snaky) ? params.delete(:snaky) : Response::DEFAULT_OPTIONS[:snaky]
272
+ params = authenticator.apply(params)
273
+ # authenticator may add :headers, and we remove them here
274
+ headers = params.delete(:headers) || {}
275
+ [parse, snaky, params, headers]
259
276
  end
260
277
 
261
- private
278
+ def execute_request(verb, url, opts = {})
279
+ url = connection.build_url(url).to_s
280
+
281
+ begin
282
+ response = connection.run_request(verb, url, opts[:body], opts[:headers]) do |req|
283
+ req.params.update(opts[:params]) if opts[:params]
284
+ yield(req) if block_given?
285
+ end
286
+ rescue Faraday::ConnectionFailed => e
287
+ raise ConnectionError, e
288
+ rescue Faraday::TimeoutError => e
289
+ raise TimeoutError, e
290
+ end
291
+
292
+ parse = opts.key?(:parse) ? opts.delete(:parse) : Response::DEFAULT_OPTIONS[:parse]
293
+ snaky = opts.key?(:snaky) ? opts.delete(:snaky) : Response::DEFAULT_OPTIONS[:snaky]
294
+
295
+ Response.new(response, parse: parse, snaky: snaky)
296
+ end
262
297
 
263
298
  # Returns the authenticator object
264
299
  #
@@ -267,26 +302,53 @@ module OAuth2
267
302
  Authenticator.new(id, secret, options[:auth_scheme])
268
303
  end
269
304
 
305
+ def parse_response_legacy(response, access_token_opts, extract_access_token)
306
+ access_token = build_access_token_legacy(response, access_token_opts, extract_access_token)
307
+
308
+ return access_token if access_token
309
+
310
+ if options[:raise_errors]
311
+ error = Error.new(response)
312
+ raise(error)
313
+ end
314
+
315
+ nil
316
+ end
317
+
318
+ def parse_response(response, access_token_opts)
319
+ access_token_class = options[:access_token_class]
320
+ data = response.parsed
321
+
322
+ unless data.is_a?(Hash) && !data.empty?
323
+ return unless options[:raise_errors]
324
+
325
+ error = Error.new(response)
326
+ raise(error)
327
+ end
328
+
329
+ build_access_token(response, access_token_opts, access_token_class)
330
+ end
331
+
270
332
  # Builds the access token from the response of the HTTP call
271
333
  #
272
334
  # @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)
276
-
277
- hash = parsed_response.merge(access_token_opts)
278
-
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)
335
+ def build_access_token(response, access_token_opts, access_token_class)
336
+ access_token_class.from_hash(self, response.parsed.merge(access_token_opts)).tap do |access_token|
337
+ access_token.response = response if access_token.respond_to?(:response=)
285
338
  end
286
339
  end
287
340
 
341
+ # Builds the access token from the response of the HTTP call with legacy extract_access_token
342
+ #
343
+ # @return [AccessToken] the initialized AccessToken
344
+ def build_access_token_legacy(response, access_token_opts, extract_access_token)
345
+ extract_access_token.call(self, response.parsed.merge(access_token_opts))
346
+ rescue StandardError
347
+ nil
348
+ end
349
+
288
350
  def oauth_debug_logging(builder)
289
- builder.response :logger, options[:logger], :bodies => true if ENV['OAUTH_DEBUG'] == 'true'
351
+ builder.response :logger, options[:logger], bodies: true if ENV['OAUTH_DEBUG'] == 'true'
290
352
  end
291
353
  end
292
354
  end
data/lib/oauth2/error.rb CHANGED
@@ -2,41 +2,58 @@
2
2
 
3
3
  module OAuth2
4
4
  class Error < StandardError
5
- attr_reader :response, :code, :description
5
+ attr_reader :response, :body, :code, :description
6
6
 
7
- # standard error values include:
8
- # :invalid_request, :invalid_client, :invalid_token, :invalid_grant, :unsupported_grant_type, :invalid_scope
7
+ # standard error codes include:
8
+ # 'invalid_request', 'invalid_client', 'invalid_token', 'invalid_grant', 'unsupported_grant_type', 'invalid_scope'
9
+ # response might be a Response object, or the response.parsed hash
9
10
  def initialize(response)
10
- response.error = self
11
11
  @response = response
12
-
13
- if response.parsed.is_a?(Hash)
14
- @code = response.parsed['error']
15
- @description = response.parsed['error_description']
16
- error_description = "#{@code}: #{@description}"
12
+ if response.respond_to?(:parsed)
13
+ if response.parsed.is_a?(Hash)
14
+ @code = response.parsed['error']
15
+ @description = response.parsed['error_description']
16
+ end
17
+ elsif response.is_a?(Hash)
18
+ @code = response['error']
19
+ @description = response['error_description']
17
20
  end
18
-
19
- super(error_message(response.body, :error_description => error_description))
21
+ @body = if response.respond_to?(:body)
22
+ response.body
23
+ else
24
+ @response
25
+ end
26
+ message_opts = parse_error_description(@code, @description)
27
+ super(error_message(@body, message_opts))
20
28
  end
21
29
 
22
- # Makes a error message
23
- # @param [String] response_body response body of request
24
- # @param [String] opts :error_description error description to show first line
30
+ private
31
+
25
32
  def error_message(response_body, opts = {})
26
- message = []
33
+ lines = []
34
+
35
+ lines << opts[:error_description] if opts[:error_description]
27
36
 
28
- opts[:error_description] && (message << opts[:error_description])
37
+ error_string = if response_body.respond_to?(:encode) && opts[:error_description].respond_to?(:encoding)
38
+ script_encoding = opts[:error_description].encoding
39
+ response_body.encode(script_encoding, invalid: :replace, undef: :replace)
40
+ else
41
+ response_body
42
+ end
43
+
44
+ lines << error_string
45
+
46
+ lines.join("\n")
47
+ end
29
48
 
30
- error_message = if opts[:error_description] && opts[:error_description].respond_to?(:encoding)
31
- script_encoding = opts[:error_description].encoding
32
- response_body.encode(script_encoding, :invalid => :replace, :undef => :replace)
33
- else
34
- response_body
35
- end
49
+ def parse_error_description(code, description)
50
+ return {} unless code || description
36
51
 
37
- message << error_message
52
+ error_description = ''
53
+ error_description += "#{code}: " if code
54
+ error_description += description if description
38
55
 
39
- message.join("\n")
56
+ {error_description: error_description}
40
57
  end
41
58
  end
42
59
  end
@@ -1,27 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'multi_json'
3
+ require 'json'
4
4
  require 'multi_xml'
5
5
  require 'rack'
6
6
 
7
7
  module OAuth2
8
8
  # OAuth2::Response class
9
9
  class Response
10
+ DEFAULT_OPTIONS = {
11
+ parse: :automatic,
12
+ snaky: true,
13
+ }.freeze
10
14
  attr_reader :response
11
- attr_accessor :error, :options
15
+ attr_accessor :options
12
16
 
13
17
  # Procs that, when called, will parse a response body according
14
18
  # to the specified format.
15
19
  @@parsers = {
16
- :json => lambda { |body| MultiJson.load(body) rescue body }, # rubocop:disable Style/RescueModifier
17
- :query => lambda { |body| Rack::Utils.parse_query(body) },
18
- :text => lambda { |body| body },
20
+ query: ->(body) { Rack::Utils.parse_query(body) },
21
+ text: ->(body) { body },
19
22
  }
20
23
 
21
24
  # Content type assignments for various potential HTTP content types.
22
25
  @@content_types = {
23
- 'application/json' => :json,
24
- 'text/javascript' => :json,
25
26
  'application/x-www-form-urlencoded' => :query,
26
27
  'text/plain' => :text,
27
28
  }
@@ -42,12 +43,17 @@ module OAuth2
42
43
  # Initializes a Response instance
43
44
  #
44
45
  # @param [Faraday::Response] response The Faraday response instance
45
- # @param [Hash] opts options in which to initialize the instance
46
- # @option opts [Symbol] :parse (:automatic) how to parse the response body. one of :query (for x-www-form-urlencoded),
46
+ # @param [Symbol] parse (:automatic) how to parse the response body. one of :query (for x-www-form-urlencoded),
47
47
  # :json, or :automatic (determined by Content-Type response header)
48
- def initialize(response, opts = {})
48
+ # @param [true, false] snaky (true) Convert @parsed to a snake-case,
49
+ # indifferent-access SnakyHash::StringKeyed, which is a subclass of Hashie::Mash (from hashie gem)?
50
+ # @param [Hash] options all other options for initializing the instance
51
+ def initialize(response, parse: :automatic, snaky: true, **options)
49
52
  @response = response
50
- @options = {:parse => :automatic}.merge(opts)
53
+ @options = {
54
+ parse: parse,
55
+ snaky: snaky,
56
+ }.merge(options)
51
57
  end
52
58
 
53
59
  # The HTTP response headers
@@ -65,29 +71,78 @@ module OAuth2
65
71
  response.body || ''
66
72
  end
67
73
 
68
- # The parsed response body.
69
- # Will attempt to parse application/x-www-form-urlencoded and
70
- # application/json Content-Type response bodies
74
+ # The {#response} {#body} as parsed by {#parser}.
75
+ #
76
+ # @return [Object] As returned by {#parser} if it is #call-able.
77
+ # @return [nil] If the {#parser} is not #call-able.
71
78
  def parsed
72
- return nil unless @@parsers.key?(parser)
79
+ return @parsed if defined?(@parsed)
80
+
81
+ @parsed =
82
+ if parser.respond_to?(:call)
83
+ case parser.arity
84
+ when 0
85
+ parser.call
86
+ when 1
87
+ parser.call(body)
88
+ else
89
+ parser.call(body, response)
90
+ end
91
+ end
73
92
 
74
- @parsed ||= @@parsers[parser].call(body)
93
+ @parsed = SnakyHash::StringKeyed.new(@parsed) if options[:snaky] && @parsed.is_a?(Hash)
94
+
95
+ @parsed
75
96
  end
76
97
 
77
98
  # Attempts to determine the content type of the response.
78
99
  def content_type
79
- ((response.headers.values_at('content-type', 'Content-Type').compact.first || '').split(';').first || '').strip
100
+ return nil unless response.headers
101
+
102
+ ((response.headers.values_at('content-type', 'Content-Type').compact.first || '').split(';').first || '').strip.downcase
80
103
  end
81
104
 
82
- # Determines the parser that will be used to supply the content of #parsed
105
+ # Determines the parser (a Proc or other Object which responds to #call)
106
+ # that will be passed the {#body} (and optional {#response}) to supply
107
+ # {#parsed}.
108
+ #
109
+ # The parser can be supplied as the +:parse+ option in the form of a Proc
110
+ # (or other Object responding to #call) or a Symbol. In the latter case,
111
+ # the actual parser will be looked up in {@@parsers} by the supplied Symbol.
112
+ #
113
+ # If no +:parse+ option is supplied, the lookup Symbol will be determined
114
+ # by looking up {#content_type} in {@@content_types}.
115
+ #
116
+ # If {#parser} is a Proc, it will be called with no arguments, just
117
+ # {#body}, or {#body} and {#response}, depending on the Proc's arity.
118
+ #
119
+ # @return [Proc, #call] If a parser was found.
120
+ # @return [nil] If no parser was found.
83
121
  def parser
84
- return options[:parse].to_sym if @@parsers.key?(options[:parse])
122
+ return @parser if defined?(@parser)
123
+
124
+ @parser =
125
+ if options[:parse].respond_to?(:call)
126
+ options[:parse]
127
+ elsif options[:parse]
128
+ @@parsers[options[:parse].to_sym]
129
+ end
85
130
 
86
- @@content_types[content_type]
131
+ @parser ||= @@parsers[@@content_types[content_type]]
87
132
  end
88
133
  end
89
134
  end
90
135
 
91
- OAuth2::Response.register_parser(:xml, ['text/xml', 'application/rss+xml', 'application/rdf+xml', 'application/atom+xml']) do |body|
92
- MultiXml.parse(body) rescue body # rubocop:disable Style/RescueModifier
136
+ OAuth2::Response.register_parser(:xml, ['text/xml', 'application/rss+xml', 'application/rdf+xml', 'application/atom+xml', 'application/xml']) do |body|
137
+ next body unless body.respond_to?(:to_str)
138
+
139
+ MultiXml.parse(body)
140
+ end
141
+
142
+ OAuth2::Response.register_parser(:json, ['application/json', 'text/javascript', 'application/hal+json', 'application/vnd.collection+json', 'application/vnd.api+json', 'application/problem+json']) do |body|
143
+ next body unless body.respond_to?(:to_str)
144
+
145
+ body = body.dup.force_encoding(::Encoding::ASCII_8BIT) if body.respond_to?(:force_encoding)
146
+
147
+ ::JSON.parse(body)
93
148
  end