oauth2 1.4.10 → 2.0.3

Sign up to get free protection for your applications and to get access to all the features.
data/lib/oauth2/client.rb CHANGED
@@ -5,6 +5,8 @@ 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
12
  RESERVED_PARAM_KEYS = %w[headers parse].freeze
@@ -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 [Symbol] :snaky @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,52 @@ 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 @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
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]
156
160
  params = params.map do |key, value|
157
161
  if RESERVED_PARAM_KEYS.include?(key)
158
162
  [key.to_sym, value]
159
163
  else
160
164
  [key, value]
161
165
  end
162
- end
163
- params = Hash[params]
166
+ end.to_h
167
+
168
+ request_opts = {
169
+ raise_errors: options[:raise_errors],
170
+ parse: params.delete(:parse),
171
+ snaky: params.delete(:snaky),
172
+ }
164
173
 
165
174
  params = authenticator.apply(params)
166
- opts = {:raise_errors => options[:raise_errors], :parse => params.delete(:parse)}
167
175
  headers = params.delete(:headers) || {}
168
176
  if options[:token_method] == :post
169
- opts[:body] = params
170
- opts[:headers] = {'Content-Type' => 'application/x-www-form-urlencoded'}
177
+ request_opts[:body] = params
178
+ request_opts[:headers] = {'Content-Type' => 'application/x-www-form-urlencoded'}
171
179
  else
172
- opts[:params] = params
173
- opts[:headers] = {}
180
+ request_opts[:params] = params
181
+ request_opts[:headers] = {}
174
182
  end
175
- opts[:headers] = opts[:headers].merge(headers)
183
+ request_opts[:headers].merge!(headers)
176
184
  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
183
- end
184
-
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
185
+ http_method = :post if http_method == :post_with_query_string
186
+ response = request(http_method, token_url, request_opts, &block)
187
+
188
+ # In v1.4.x, the deprecated extract_access_token option retrieves the token from the response.
189
+ # We preserve this behavior here, but a custom access_token_class that implements #from_hash
190
+ # should be used instead.
191
+ if extract_access_token
192
+ parse_response_with_legacy_extract(response, access_token_opts, extract_access_token)
193
+ else
194
+ parse_response(response, access_token_opts)
195
195
  end
196
-
197
- access_token
198
196
  end
199
197
 
200
198
  # The Authorization Code strategy
@@ -253,13 +251,25 @@ module OAuth2
253
251
  end
254
252
  end
255
253
 
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)
259
- end
260
-
261
254
  private
262
255
 
256
+ def execute_request(verb, url, opts = {})
257
+ url = connection.build_url(url).to_s
258
+
259
+ begin
260
+ response = connection.run_request(verb, url, opts[:body], opts[:headers]) do |req|
261
+ req.params.update(opts[:params]) if opts[:params]
262
+ yield(req) if block_given?
263
+ end
264
+ rescue Faraday::ConnectionFailed => e
265
+ raise ConnectionError, e
266
+ rescue Faraday::TimeoutError => e
267
+ raise TimeoutError, e
268
+ end
269
+
270
+ Response.new(response, parse: opts[:parse], snaky: opts[:snaky])
271
+ end
272
+
263
273
  # Returns the authenticator object
264
274
  #
265
275
  # @return [Authenticator] the initialized Authenticator
@@ -267,26 +277,53 @@ module OAuth2
267
277
  Authenticator.new(id, secret, options[:auth_scheme])
268
278
  end
269
279
 
280
+ def parse_response_with_legacy_extract(response, access_token_opts, extract_access_token)
281
+ access_token = build_access_token_legacy_extract(response, access_token_opts, extract_access_token)
282
+
283
+ return access_token if access_token
284
+
285
+ if options[:raise_errors]
286
+ error = Error.new(response)
287
+ raise(error)
288
+ end
289
+
290
+ nil
291
+ end
292
+
293
+ def parse_response(response, access_token_opts)
294
+ access_token_class = options[:access_token_class]
295
+ data = response.parsed
296
+
297
+ unless data.is_a?(Hash) && access_token_class.contains_token?(data)
298
+ return unless options[:raise_errors]
299
+
300
+ error = Error.new(response)
301
+ raise(error)
302
+ end
303
+
304
+ build_access_token(response, access_token_opts, access_token_class)
305
+ end
306
+
270
307
  # Builds the access token from the response of the HTTP call
271
308
  #
272
309
  # @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)
310
+ def build_access_token(response, access_token_opts, access_token_class)
311
+ access_token_class.from_hash(self, response.parsed.merge(access_token_opts)).tap do |access_token|
312
+ access_token.response = response if access_token.respond_to?(:response=)
285
313
  end
286
314
  end
287
315
 
316
+ # Builds the access token from the response of the HTTP call with legacy extract_access_token
317
+ #
318
+ # @return [AccessToken] the initialized AccessToken
319
+ def build_access_token_legacy_extract(response, access_token_opts, extract_access_token)
320
+ extract_access_token.call(self, response.parsed.merge(access_token_opts))
321
+ rescue StandardError
322
+ nil
323
+ end
324
+
288
325
  def oauth_debug_logging(builder)
289
- builder.response :logger, options[:logger], :bodies => true if ENV['OAUTH_DEBUG'] == 'true'
326
+ builder.response :logger, options[:logger], bodies: true if ENV['OAUTH_DEBUG'] == 'true'
290
327
  end
291
328
  end
292
329
  end
data/lib/oauth2/error.rb CHANGED
@@ -4,39 +4,48 @@ module OAuth2
4
4
  class Error < StandardError
5
5
  attr_reader :response, :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
9
  def initialize(response)
10
- response.error = self
11
10
  @response = response
11
+ message_opts = {}
12
12
 
13
13
  if response.parsed.is_a?(Hash)
14
14
  @code = response.parsed['error']
15
15
  @description = response.parsed['error_description']
16
- error_description = "#{@code}: #{@description}"
16
+ message_opts = parse_error_description(@code, @description)
17
17
  end
18
18
 
19
- super(error_message(response.body, :error_description => error_description))
19
+ super(error_message(response.body, message_opts))
20
20
  end
21
21
 
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
22
+ private
23
+
25
24
  def error_message(response_body, opts = {})
26
- message = []
25
+ lines = []
26
+
27
+ lines << opts[:error_description] if opts[:error_description]
28
+
29
+ error_string = if response_body.respond_to?(:encode) && opts[:error_description].respond_to?(:encoding)
30
+ script_encoding = opts[:error_description].encoding
31
+ response_body.encode(script_encoding, invalid: :replace, undef: :replace)
32
+ else
33
+ response_body
34
+ end
27
35
 
28
- opts[:error_description] && (message << opts[:error_description])
36
+ lines << error_string
37
+
38
+ lines.join("\n")
39
+ end
29
40
 
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
41
+ def parse_error_description(code, description)
42
+ return {} unless code || description
36
43
 
37
- message << error_message
44
+ error_description = ''
45
+ error_description += "#{code}: " if code
46
+ error_description += description if description
38
47
 
39
- message.join("\n")
48
+ {error_description: error_description}
40
49
  end
41
50
  end
42
51
  end
@@ -1,6 +1,6 @@
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
 
@@ -8,20 +8,17 @@ module OAuth2
8
8
  # OAuth2::Response class
9
9
  class Response
10
10
  attr_reader :response
11
- attr_accessor :error, :options
11
+ attr_accessor :options
12
12
 
13
13
  # Procs that, when called, will parse a response body according
14
14
  # to the specified format.
15
15
  @@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 },
16
+ query: ->(body) { Rack::Utils.parse_query(body) },
17
+ text: ->(body) { body },
19
18
  }
20
19
 
21
20
  # Content type assignments for various potential HTTP content types.
22
21
  @@content_types = {
23
- 'application/json' => :json,
24
- 'text/javascript' => :json,
25
22
  'application/x-www-form-urlencoded' => :query,
26
23
  'text/plain' => :text,
27
24
  }
@@ -42,12 +39,17 @@ module OAuth2
42
39
  # Initializes a Response instance
43
40
  #
44
41
  # @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),
42
+ # @param [Symbol] parse (:automatic) how to parse the response body. one of :query (for x-www-form-urlencoded),
47
43
  # :json, or :automatic (determined by Content-Type response header)
48
- def initialize(response, opts = {})
44
+ # @param [true, false] snaky (true) Convert @parsed to a snake-case,
45
+ # indifferent-access OAuth2::SnakyHash, which is a subclass of Hashie::Mash::Rash (from rash_alt gem)?
46
+ # @param [Hash] options all other options for initializing the instance
47
+ def initialize(response, parse: :automatic, snaky: true, **options)
49
48
  @response = response
50
- @options = {:parse => :automatic}.merge(opts)
49
+ @options = {
50
+ parse: parse,
51
+ snaky: snaky,
52
+ }.merge(options)
51
53
  end
52
54
 
53
55
  # The HTTP response headers
@@ -65,29 +67,78 @@ module OAuth2
65
67
  response.body || ''
66
68
  end
67
69
 
68
- # The parsed response body.
69
- # Will attempt to parse application/x-www-form-urlencoded and
70
- # application/json Content-Type response bodies
70
+ # The {#response} {#body} as parsed by {#parser}.
71
+ #
72
+ # @return [Object] As returned by {#parser} if it is #call-able.
73
+ # @return [nil] If the {#parser} is not #call-able.
71
74
  def parsed
72
- return nil unless @@parsers.key?(parser)
75
+ return @parsed if defined?(@parsed)
76
+
77
+ @parsed =
78
+ if parser.respond_to?(:call)
79
+ case parser.arity
80
+ when 0
81
+ parser.call
82
+ when 1
83
+ parser.call(body)
84
+ else
85
+ parser.call(body, response)
86
+ end
87
+ end
73
88
 
74
- @parsed ||= @@parsers[parser].call(body)
89
+ @parsed = OAuth2::SnakyHash.new(@parsed) if options[:snaky] && @parsed.is_a?(Hash)
90
+
91
+ @parsed
75
92
  end
76
93
 
77
94
  # Attempts to determine the content type of the response.
78
95
  def content_type
79
- ((response.headers.values_at('content-type', 'Content-Type').compact.first || '').split(';').first || '').strip
96
+ return nil unless response.headers
97
+
98
+ ((response.headers.values_at('content-type', 'Content-Type').compact.first || '').split(';').first || '').strip.downcase
80
99
  end
81
100
 
82
- # Determines the parser that will be used to supply the content of #parsed
101
+ # Determines the parser (a Proc or other Object which responds to #call)
102
+ # that will be passed the {#body} (and optional {#response}) to supply
103
+ # {#parsed}.
104
+ #
105
+ # The parser can be supplied as the +:parse+ option in the form of a Proc
106
+ # (or other Object responding to #call) or a Symbol. In the latter case,
107
+ # the actual parser will be looked up in {@@parsers} by the supplied Symbol.
108
+ #
109
+ # If no +:parse+ option is supplied, the lookup Symbol will be determined
110
+ # by looking up {#content_type} in {@@content_types}.
111
+ #
112
+ # If {#parser} is a Proc, it will be called with no arguments, just
113
+ # {#body}, or {#body} and {#response}, depending on the Proc's arity.
114
+ #
115
+ # @return [Proc, #call] If a parser was found.
116
+ # @return [nil] If no parser was found.
83
117
  def parser
84
- return options[:parse].to_sym if @@parsers.key?(options[:parse])
118
+ return @parser if defined?(@parser)
119
+
120
+ @parser =
121
+ if options[:parse].respond_to?(:call)
122
+ options[:parse]
123
+ elsif options[:parse]
124
+ @@parsers[options[:parse].to_sym]
125
+ end
85
126
 
86
- @@content_types[content_type]
127
+ @parser ||= @@parsers[@@content_types[content_type]]
87
128
  end
88
129
  end
89
130
  end
90
131
 
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
132
+ OAuth2::Response.register_parser(:xml, ['text/xml', 'application/rss+xml', 'application/rdf+xml', 'application/atom+xml', 'application/xml']) do |body|
133
+ next body unless body.respond_to?(:to_str)
134
+
135
+ MultiXml.parse(body)
136
+ end
137
+
138
+ 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|
139
+ next body unless body.respond_to?(:to_str)
140
+
141
+ body = body.dup.force_encoding(::Encoding::ASCII_8BIT) if body.respond_to?(:force_encoding)
142
+
143
+ ::JSON.parse(body)
93
144
  end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OAuth2
4
+ # Hash which allow assign string key in camel case
5
+ # and query on both camel and snake case
6
+ class SnakyHash < ::Hashie::Mash::Rash
7
+ end
8
+ end
@@ -10,15 +10,22 @@ module OAuth2
10
10
  #
11
11
  # Sample usage:
12
12
  # client = OAuth2::Client.new(client_id, client_secret,
13
- # :site => 'http://localhost:8080')
13
+ # :site => 'http://localhost:8080',
14
+ # :auth_scheme => :request_body)
14
15
  #
15
- # params = {:hmac_secret => "some secret",
16
- # # or :private_key => "private key string",
17
- # :iss => "http://localhost:3001",
18
- # :prn => "me@here.com",
19
- # :exp => Time.now.utc.to_i + 3600}
16
+ # claim_set = {
17
+ # :iss => "http://localhost:3001",
18
+ # :aud => "http://localhost:8080/oauth2/token",
19
+ # :sub => "me@example.com",
20
+ # :exp => Time.now.utc.to_i + 3600,
21
+ # }
20
22
  #
21
- # access = client.assertion.get_token(params)
23
+ # encoding = {
24
+ # :algorithm => 'HS256',
25
+ # :key => 'secret_key',
26
+ # }
27
+ #
28
+ # access = client.assertion.get_token(claim_set, encoding)
22
29
  # access.token # actual access_token string
23
30
  # access.get("/api/stuff") # making api calls with access token in header
24
31
  #
@@ -32,45 +39,63 @@ module OAuth2
32
39
 
33
40
  # Retrieve an access token given the specified client.
34
41
  #
35
- # @param [Hash] params assertion params
36
- # pass either :hmac_secret or :private_key, but not both.
42
+ # @param [Hash] claims the hash representation of the claims that should be encoded as a JWT (JSON Web Token)
43
+ #
44
+ # For reading on JWT and claim keys:
45
+ # @see https://github.com/jwt/ruby-jwt
46
+ # @see https://datatracker.ietf.org/doc/html/rfc7519#section-4.1
47
+ # @see https://datatracker.ietf.org/doc/html/rfc7523#section-3
48
+ # @see https://www.iana.org/assignments/jwt/jwt.xhtml
49
+ #
50
+ # There are many possible claim keys, and applications may ask for their own custom keys.
51
+ # Some typically required ones:
52
+ # :iss (issuer)
53
+ # :aud (audience)
54
+ # :sub (subject) -- formerly :prn https://datatracker.ietf.org/doc/html/draft-ietf-oauth-json-web-token-06#appendix-F
55
+ # :exp, (expiration time) -- in seconds, e.g. Time.now.utc.to_i + 3600
56
+ #
57
+ # Note that this method does *not* validate presence of those four claim keys indicated as required by RFC 7523.
58
+ # There are endpoints that may not conform with this RFC, and this gem should still work for those use cases.
59
+ #
60
+ # @param [Hash] encoding_opts a hash containing instructions on how the JWT should be encoded
61
+ # @option algorithm [String] the algorithm with which you would like the JWT to be encoded
62
+ # @option key [Object] the key with which you would like to encode the JWT
37
63
  #
38
- # params :hmac_secret, secret string.
39
- # params :private_key, private key string.
64
+ # These two options are passed directly to `JWT.encode`. For supported encoding arguments:
65
+ # @see https://github.com/jwt/ruby-jwt#algorithms-and-usage
66
+ # @see https://datatracker.ietf.org/doc/html/rfc7518#section-3.1
40
67
  #
41
- # params :iss, issuer
42
- # params :aud, audience, optional
43
- # params :prn, principal, current user
44
- # params :exp, expired at, in seconds, like Time.now.utc.to_i + 3600
68
+ # The object type of `:key` may depend on the value of `:algorithm`. Sample arguments:
69
+ # get_token(claim_set, {:algorithm => 'HS256', :key => 'secret_key'})
70
+ # get_token(claim_set, {:algorithm => 'RS256', :key => OpenSSL::PKCS12.new(File.read('my_key.p12'), 'not_secret')})
45
71
  #
46
- # @param [Hash] opts options
47
- def get_token(params = {}, opts = {})
48
- hash = build_request(params)
49
- @client.get_token(hash, opts.merge('refresh_token' => nil))
72
+ # @param [Hash] request_opts options that will be used to assemble the request
73
+ # @option request_opts [String] :scope the url parameter `scope` that may be required by some endpoints
74
+ # @see https://datatracker.ietf.org/doc/html/rfc7521#section-4.1
75
+ #
76
+ # @param [Hash] response_opts this will be merged with the token response to create the AccessToken object
77
+ # @see the access_token_opts argument to Client#get_token
78
+
79
+ def get_token(claims, encoding_opts, request_opts = {}, response_opts = {})
80
+ assertion = build_assertion(claims, encoding_opts)
81
+ params = build_request(assertion, request_opts)
82
+
83
+ @client.get_token(params, response_opts.merge('refresh_token' => nil))
50
84
  end
51
85
 
52
- def build_request(params)
53
- assertion = build_assertion(params)
86
+ private
87
+
88
+ def build_request(assertion, request_opts = {})
54
89
  {
55
- :grant_type => 'assertion',
56
- :assertion_type => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
57
- :assertion => assertion,
58
- :scope => params[:scope],
59
- }
90
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
91
+ assertion: assertion,
92
+ }.merge(request_opts)
60
93
  end
61
94
 
62
- def build_assertion(params)
63
- claims = {
64
- :iss => params[:iss],
65
- :aud => params[:aud],
66
- :prn => params[:prn],
67
- :exp => params[:exp],
68
- }
69
- if params[:hmac_secret]
70
- JWT.encode(claims, params[:hmac_secret], 'HS256')
71
- elsif params[:private_key]
72
- JWT.encode(claims, params[:private_key], 'RS256')
73
- end
95
+ def build_assertion(claims, encoding_opts)
96
+ raise ArgumentError.new(message: 'Please provide an encoding_opts hash with :algorithm and :key') if !encoding_opts.is_a?(Hash) || (%i[algorithm key] - encoding_opts.keys).any?
97
+
98
+ JWT.encode(claims, encoding_opts[:key], encoding_opts[:algorithm])
74
99
  end
75
100
  end
76
101
  end
@@ -17,6 +17,7 @@ module OAuth2
17
17
  #
18
18
  # @param [Hash] params additional query parameters for the URL
19
19
  def authorize_url(params = {})
20
+ assert_valid_params(params)
20
21
  @client.authorize_url(authorize_params.merge(params))
21
22
  end
22
23
 
@@ -28,8 +29,18 @@ module OAuth2
28
29
  # @note that you must also provide a :redirect_uri with most OAuth 2.0 providers
29
30
  def get_token(code, params = {}, opts = {})
30
31
  params = {'grant_type' => 'authorization_code', 'code' => code}.merge(@client.redirection_params).merge(params)
32
+ params_dup = params.dup
33
+ params.each_key do |key|
34
+ params_dup[key.to_s] = params_dup.delete(key) if key.is_a?(Symbol)
35
+ end
31
36
 
32
- @client.get_token(params, opts)
37
+ @client.get_token(params_dup, opts)
38
+ end
39
+
40
+ private
41
+
42
+ def assert_valid_params(params)
43
+ raise(ArgumentError, 'client_secret is not allowed in authorize URL query params') if params.key?(:client_secret) || params.key?('client_secret')
33
44
  end
34
45
  end
35
46
  end