oauth2 1.4.9 → 2.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +706 -88
  4. data/CITATION.cff +20 -0
  5. data/CODE_OF_CONDUCT.md +24 -23
  6. data/CONTRIBUTING.md +229 -0
  7. data/FUNDING.md +77 -0
  8. data/{LICENSE → LICENSE.txt} +2 -2
  9. data/OIDC.md +158 -0
  10. data/README.md +1513 -251
  11. data/REEK +0 -0
  12. data/RUBOCOP.md +71 -0
  13. data/SECURITY.md +21 -0
  14. data/lib/oauth2/access_token.rb +276 -39
  15. data/lib/oauth2/authenticator.rb +45 -8
  16. data/lib/oauth2/client.rb +406 -129
  17. data/lib/oauth2/error.rb +59 -24
  18. data/lib/oauth2/filtered_attributes.rb +52 -0
  19. data/lib/oauth2/response.rb +127 -36
  20. data/lib/oauth2/strategy/assertion.rb +68 -40
  21. data/lib/oauth2/strategy/auth_code.rb +25 -4
  22. data/lib/oauth2/strategy/client_credentials.rb +3 -3
  23. data/lib/oauth2/strategy/implicit.rb +17 -2
  24. data/lib/oauth2/strategy/password.rb +14 -4
  25. data/lib/oauth2/version.rb +1 -59
  26. data/lib/oauth2.rb +79 -12
  27. data/sig/oauth2/access_token.rbs +25 -0
  28. data/sig/oauth2/authenticator.rbs +22 -0
  29. data/sig/oauth2/client.rbs +52 -0
  30. data/sig/oauth2/error.rbs +8 -0
  31. data/sig/oauth2/filtered_attributes.rbs +6 -0
  32. data/sig/oauth2/response.rbs +18 -0
  33. data/sig/oauth2/strategy.rbs +34 -0
  34. data/sig/oauth2/version.rbs +5 -0
  35. data/sig/oauth2.rbs +9 -0
  36. data.tar.gz.sig +0 -0
  37. metadata +336 -89
  38. metadata.gz.sig +0 -0
  39. data/lib/oauth2/mac_token.rb +0 -130
  40. data/spec/fixtures/README.md +0 -11
  41. data/spec/fixtures/RS256/jwtRS256.key +0 -51
  42. data/spec/fixtures/RS256/jwtRS256.key.pub +0 -14
  43. data/spec/helper.rb +0 -33
  44. data/spec/oauth2/access_token_spec.rb +0 -218
  45. data/spec/oauth2/authenticator_spec.rb +0 -86
  46. data/spec/oauth2/client_spec.rb +0 -556
  47. data/spec/oauth2/mac_token_spec.rb +0 -122
  48. data/spec/oauth2/response_spec.rb +0 -96
  49. data/spec/oauth2/strategy/assertion_spec.rb +0 -113
  50. data/spec/oauth2/strategy/auth_code_spec.rb +0 -108
  51. data/spec/oauth2/strategy/base_spec.rb +0 -7
  52. data/spec/oauth2/strategy/client_credentials_spec.rb +0 -71
  53. data/spec/oauth2/strategy/implicit_spec.rb +0 -28
  54. data/spec/oauth2/strategy/password_spec.rb +0 -58
  55. data/spec/oauth2/version_spec.rb +0 -23
data/lib/oauth2/error.rb CHANGED
@@ -1,42 +1,77 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OAuth2
4
+ # Represents an OAuth2 error condition.
5
+ #
6
+ # Wraps details from an OAuth2::Response or Hash payload returned by an
7
+ # authorization server, exposing error code and description per RFC 6749.
4
8
  class Error < StandardError
5
- attr_reader :response, :code, :description
9
+ # @return [OAuth2::Response, Hash, Object] Original response or payload used to build the error
10
+ # @return [String] Raw body content (if available)
11
+ # @return [String, nil] Error code (e.g., 'invalid_grant')
12
+ # @return [String, nil] Human-readable description for the error
13
+ attr_reader :response, :body, :code, :description
6
14
 
7
- # standard error values include:
8
- # :invalid_request, :invalid_client, :invalid_token, :invalid_grant, :unsupported_grant_type, :invalid_scope
15
+ # Create a new OAuth2::Error
16
+ #
17
+ # @param [OAuth2::Response, Hash, Object] response A Response or error payload
9
18
  def initialize(response)
10
- response.error = self
11
19
  @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}"
20
+ if response.respond_to?(:parsed)
21
+ if response.parsed.is_a?(Hash)
22
+ @code = response.parsed["error"]
23
+ @description = response.parsed["error_description"]
24
+ end
25
+ elsif response.is_a?(Hash)
26
+ @code = response["error"]
27
+ @description = response["error_description"]
17
28
  end
18
-
19
- super(error_message(response.body, :error_description => error_description))
29
+ @body = if response.respond_to?(:body)
30
+ response.body
31
+ else
32
+ @response
33
+ end
34
+ message_opts = parse_error_description(@code, @description)
35
+ super(error_message(@body, message_opts))
20
36
  end
21
37
 
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
38
+ private
39
+
40
+ # Builds a multi-line error message including description and raw body.
41
+ #
42
+ # @param [String, #encode] response_body Response body content
43
+ # @param [Hash] opts Options including :error_description
44
+ # @return [String] Message suitable for StandardError
25
45
  def error_message(response_body, opts = {})
26
- message = []
46
+ lines = []
47
+
48
+ lines << opts[:error_description] if opts[:error_description]
27
49
 
28
- opts[:error_description] && (message << opts[:error_description])
50
+ error_string = if response_body.respond_to?(:encode) && opts[:error_description].respond_to?(:encoding)
51
+ script_encoding = opts[:error_description].encoding
52
+ response_body.encode(script_encoding, invalid: :replace, undef: :replace)
53
+ else
54
+ response_body
55
+ end
56
+
57
+ lines << error_string
58
+
59
+ lines.join("\n")
60
+ end
29
61
 
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
62
+ # Formats the OAuth2 error code and description into a single string.
63
+ #
64
+ # @param [String, nil] code OAuth2 error code
65
+ # @param [String, nil] description OAuth2 error description
66
+ # @return [Hash] Options hash containing :error_description when present
67
+ def parse_error_description(code, description)
68
+ return {} unless code || description
36
69
 
37
- message << error_message
70
+ error_description = ""
71
+ error_description += "#{code}: " if code
72
+ error_description += description if description
38
73
 
39
- message.join("\n")
74
+ {error_description: error_description}
40
75
  end
41
76
  end
42
77
  end
@@ -0,0 +1,52 @@
1
+ module OAuth2
2
+ # Mixin that redacts sensitive instance variables in #inspect output.
3
+ #
4
+ # Classes include this module and declare which attributes should be filtered
5
+ # using {.filtered_attributes}. Any instance variable name that includes one of
6
+ # those attribute names will be shown as [FILTERED] in the object's inspect.
7
+ module FilteredAttributes
8
+ # Hook invoked when the module is included. Extends the including class with
9
+ # class-level helpers.
10
+ #
11
+ # @param [Class] base The including class
12
+ # @return [void]
13
+ def self.included(base)
14
+ base.extend(ClassMethods)
15
+ end
16
+
17
+ # Class-level helpers for configuring filtered attributes.
18
+ module ClassMethods
19
+ # Declare attributes that should be redacted in inspect output.
20
+ #
21
+ # @param [Array<Symbol, String>] attributes One or more attribute names
22
+ # @return [void]
23
+ def filtered_attributes(*attributes)
24
+ @filtered_attribute_names = attributes.map(&:to_sym)
25
+ end
26
+
27
+ # The configured attribute names to filter.
28
+ #
29
+ # @return [Array<Symbol>]
30
+ def filtered_attribute_names
31
+ @filtered_attribute_names || []
32
+ end
33
+ end
34
+
35
+ # Custom inspect that redacts configured attributes.
36
+ #
37
+ # @return [String]
38
+ def inspect
39
+ filtered_attribute_names = self.class.filtered_attribute_names
40
+ return super if filtered_attribute_names.empty?
41
+
42
+ inspected_vars = instance_variables.map do |var|
43
+ if filtered_attribute_names.any? { |filtered_var| var.to_s.include?(filtered_var.to_s) }
44
+ "#{var}=[FILTERED]"
45
+ else
46
+ "#{var}=#{instance_variable_get(var).inspect}"
47
+ end
48
+ end
49
+ "#<#{self.class}:#{object_id} #{inspected_vars.join(", ")}>"
50
+ end
51
+ end
52
+ end
@@ -1,36 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'multi_json'
4
- require 'multi_xml'
5
- require 'rack'
3
+ require "json"
4
+ require "multi_xml"
5
+ require "rack"
6
6
 
7
7
  module OAuth2
8
- # OAuth2::Response class
8
+ # The Response class handles HTTP responses in the OAuth2 gem, providing methods
9
+ # to access and parse response data in various formats.
10
+ #
11
+ # @since 1.0.0
9
12
  class Response
13
+ # Default configuration options for Response instances
14
+ #
15
+ # @return [Hash] The default options hash
16
+ DEFAULT_OPTIONS = {
17
+ parse: :automatic,
18
+ snaky: true,
19
+ snaky_hash_klass: SnakyHash::StringKeyed,
20
+ }.freeze
21
+
22
+ # @return [Faraday::Response] The raw Faraday response object
10
23
  attr_reader :response
11
- attr_accessor :error, :options
12
24
 
13
- # Procs that, when called, will parse a response body according
14
- # to the specified format.
25
+ # @return [Hash] The options hash for this instance
26
+ attr_accessor :options
27
+
28
+ # @private
29
+ # Storage for response body parser procedures
30
+ #
31
+ # @return [Hash<Symbol, Proc>] Hash of parser procs keyed by format symbol
15
32
  @@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 },
33
+ query: ->(body) { Rack::Utils.parse_query(body) },
34
+ text: ->(body) { body },
19
35
  }
20
36
 
21
- # Content type assignments for various potential HTTP content types.
37
+ # @private
38
+ # Maps content types to parser symbols
39
+ #
40
+ # @return [Hash<String, Symbol>] Hash of content types mapped to parser symbols
22
41
  @@content_types = {
23
- 'application/json' => :json,
24
- 'text/javascript' => :json,
25
- 'application/x-www-form-urlencoded' => :query,
26
- 'text/plain' => :text,
42
+ "application/x-www-form-urlencoded" => :query,
43
+ "text/plain" => :text,
27
44
  }
28
45
 
29
46
  # Adds a new content type parser.
30
47
  #
31
- # @param [Symbol] key A descriptive symbol key such as :json or :query.
32
- # @param [Array] mime_types One or more mime types to which this parser applies.
33
- # @yield [String] A block returning parsed content.
48
+ # @param [Symbol] key A descriptive symbol key such as :json or :query
49
+ # @param [Array<String>, String] mime_types One or more mime types to which this parser applies
50
+ # @yield [String] Block that will be called to parse the response body
51
+ # @yieldparam [String] body The response body to parse
52
+ # @return [void]
34
53
  def self.register_parser(key, mime_types, &block)
35
54
  key = key.to_sym
36
55
  @@parsers[key] = block
@@ -42,52 +61,124 @@ module OAuth2
42
61
  # Initializes a Response instance
43
62
  #
44
63
  # @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),
47
- # :json, or :automatic (determined by Content-Type response header)
48
- def initialize(response, opts = {})
64
+ # @param [Symbol] parse (:automatic) How to parse the response body
65
+ # @param [Boolean] snaky (true) Whether to convert parsed response to snake_case using SnakyHash
66
+ # @param [Class, nil] snaky_hash_klass (nil) Custom class for snake_case hash conversion
67
+ # @param [Hash] options Additional options for the response
68
+ # @option options [Symbol] :parse (:automatic) Parse strategy (:query, :json, or :automatic)
69
+ # @option options [Boolean] :snaky (true) Enable/disable snake_case conversion
70
+ # @option options [Class] :snaky_hash_klass (SnakyHash::StringKeyed) Class to use for hash conversion
71
+ # @return [OAuth2::Response] The new Response instance
72
+ def initialize(response, parse: :automatic, snaky: true, snaky_hash_klass: nil, **options)
49
73
  @response = response
50
- @options = {:parse => :automatic}.merge(opts)
74
+ @options = {
75
+ parse: parse,
76
+ snaky: snaky,
77
+ snaky_hash_klass: snaky_hash_klass,
78
+ }.merge(options)
51
79
  end
52
80
 
53
81
  # The HTTP response headers
82
+ #
83
+ # @return [Hash] The response headers
54
84
  def headers
55
85
  response.headers
56
86
  end
57
87
 
58
88
  # The HTTP response status code
89
+ #
90
+ # @return [Integer] The response status code
59
91
  def status
60
92
  response.status
61
93
  end
62
94
 
63
95
  # The HTTP response body
96
+ #
97
+ # @return [String] The response body or empty string if nil
64
98
  def body
65
- response.body || ''
99
+ response.body || ""
66
100
  end
67
101
 
68
- # The parsed response body.
69
- # Will attempt to parse application/x-www-form-urlencoded and
70
- # application/json Content-Type response bodies
102
+ # The parsed response body
103
+ #
104
+ # @return [Object, SnakyHash::StringKeyed] The parsed response body
105
+ # @return [nil] If no parser is available
71
106
  def parsed
72
- return nil unless @@parsers.key?(parser)
107
+ return @parsed if defined?(@parsed)
108
+
109
+ @parsed =
110
+ if parser.respond_to?(:call)
111
+ case parser.arity
112
+ when 0
113
+ parser.call
114
+ when 1
115
+ parser.call(body)
116
+ else
117
+ parser.call(body, response)
118
+ end
119
+ end
120
+
121
+ if options[:snaky] && @parsed.is_a?(Hash)
122
+ hash_klass = options[:snaky_hash_klass] || DEFAULT_OPTIONS[:snaky_hash_klass]
123
+ @parsed = hash_klass[@parsed]
124
+ end
73
125
 
74
- @parsed ||= @@parsers[parser].call(body)
126
+ @parsed
75
127
  end
76
128
 
77
- # Attempts to determine the content type of the response.
129
+ # Determines the content type of the response
130
+ #
131
+ # @return [String, nil] The content type or nil if headers are not present
78
132
  def content_type
79
- ((response.headers.values_at('content-type', 'Content-Type').compact.first || '').split(';').first || '').strip
133
+ return unless response.headers
134
+
135
+ ((response.headers.values_at("content-type", "Content-Type").compact.first || "").split(";").first || "").strip.downcase
80
136
  end
81
137
 
82
- # Determines the parser that will be used to supply the content of #parsed
138
+ # Determines the parser to be used for the response body
139
+ #
140
+ # @note The parser can be supplied as the +:parse+ option in the form of a Proc
141
+ # (or other Object responding to #call) or a Symbol. In the latter case,
142
+ # the actual parser will be looked up in {@@parsers} by the supplied Symbol.
143
+ #
144
+ # @note If no +:parse+ option is supplied, the lookup Symbol will be determined
145
+ # by looking up {#content_type} in {@@content_types}.
146
+ #
147
+ # @note If {#parser} is a Proc, it will be called with no arguments, just
148
+ # {#body}, or {#body} and {#response}, depending on the Proc's arity.
149
+ #
150
+ # @return [Proc, #call] The parser proc or callable object
151
+ # @return [nil] If no suitable parser is found
83
152
  def parser
84
- return options[:parse].to_sym if @@parsers.key?(options[:parse])
153
+ return @parser if defined?(@parser)
85
154
 
86
- @@content_types[content_type]
155
+ @parser =
156
+ if options[:parse].respond_to?(:call)
157
+ options[:parse]
158
+ elsif options[:parse]
159
+ @@parsers[options[:parse].to_sym]
160
+ end
161
+
162
+ @parser ||= @@parsers[@@content_types[content_type]]
87
163
  end
88
164
  end
89
165
  end
90
166
 
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
167
+ # Register XML parser
168
+ # @api private
169
+ OAuth2::Response.register_parser(:xml, ["text/xml", "application/rss+xml", "application/rdf+xml", "application/atom+xml", "application/xml"]) do |body|
170
+ next body unless body.respond_to?(:to_str)
171
+
172
+ MultiXml.parse(body)
173
+ end
174
+
175
+ # Register JSON parser
176
+ # @api private
177
+ 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|
178
+ next body unless body.respond_to?(:to_str)
179
+
180
+ body = body.dup.force_encoding(Encoding::ASCII_8BIT) if body.respond_to?(:force_encoding)
181
+ next body if body.respond_to?(:empty?) && body.empty?
182
+
183
+ JSON.parse(body)
93
184
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'jwt'
3
+ require "jwt"
4
4
 
5
5
  module OAuth2
6
6
  module Strategy
@@ -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
  #
@@ -27,50 +34,71 @@ module OAuth2
27
34
  #
28
35
  # @raise [NotImplementedError]
29
36
  def authorize_url
30
- raise(NotImplementedError, 'The authorization endpoint is not used in this strategy')
37
+ raise(NotImplementedError, "The authorization endpoint is not used in this strategy")
31
38
  end
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
63
+ #
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
37
67
  #
38
- # params :hmac_secret, secret string.
39
- # params :private_key, private key string.
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')})
40
71
  #
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
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
45
75
  #
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))
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)
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
+ headers = {}
99
+ headers[:kid] = encoding_opts[:kid] if encoding_opts.key?(:kid)
100
+
101
+ JWT.encode(claims, encoding_opts[:key], encoding_opts[:algorithm], headers)
74
102
  end
75
103
  end
76
104
  end
@@ -4,19 +4,30 @@ module OAuth2
4
4
  module Strategy
5
5
  # The Authorization Code Strategy
6
6
  #
7
+ # OAuth 2.1 notes:
8
+ # - PKCE is required for all OAuth clients using the authorization code flow (especially public clients).
9
+ # This library does not enforce PKCE generation/verification; implement PKCE in your application when required.
10
+ # - Redirect URIs must be compared using exact string matching by the Authorization Server.
11
+ # This client forwards redirect_uri but does not perform server-side validation.
12
+ #
13
+ # References:
14
+ # - OAuth 2.1 draft: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13
15
+ # - OAuth for native apps (RFC 8252) and PKCE (RFC 7636)
16
+ #
7
17
  # @see http://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-15#section-4.1
8
18
  class AuthCode < Base
9
19
  # The required query parameters for the authorize URL
10
20
  #
11
21
  # @param [Hash] params additional query parameters
12
22
  def authorize_params(params = {})
13
- params.merge('response_type' => 'code', 'client_id' => @client.id)
23
+ params.merge("response_type" => "code", "client_id" => @client.id)
14
24
  end
15
25
 
16
26
  # The authorization URL endpoint of the provider
17
27
  #
18
28
  # @param [Hash] params additional query parameters for the URL
19
29
  def authorize_url(params = {})
30
+ assert_valid_params(params)
20
31
  @client.authorize_url(authorize_params.merge(params))
21
32
  end
22
33
 
@@ -24,12 +35,22 @@ module OAuth2
24
35
  #
25
36
  # @param [String] code The Authorization Code value
26
37
  # @param [Hash] params additional params
27
- # @param [Hash] opts options
38
+ # @param [Hash] opts access_token_opts, @see Client#get_token
28
39
  # @note that you must also provide a :redirect_uri with most OAuth 2.0 providers
29
40
  def get_token(code, params = {}, opts = {})
30
- params = {'grant_type' => 'authorization_code', 'code' => code}.merge(@client.redirection_params).merge(params)
41
+ params = {"grant_type" => "authorization_code", "code" => code}.merge(@client.redirection_params).merge(params)
42
+ params_dup = params.dup
43
+ params.each_key do |key|
44
+ params_dup[key.to_s] = params_dup.delete(key) if key.is_a?(Symbol)
45
+ end
46
+
47
+ @client.get_token(params_dup, opts)
48
+ end
49
+
50
+ private
31
51
 
32
- @client.get_token(params, opts)
52
+ def assert_valid_params(params)
53
+ raise(ArgumentError, "client_secret is not allowed in authorize URL query params") if params.key?(:client_secret) || params.key?("client_secret")
33
54
  end
34
55
  end
35
56
  end
@@ -10,7 +10,7 @@ module OAuth2
10
10
  #
11
11
  # @raise [NotImplementedError]
12
12
  def authorize_url
13
- raise(NotImplementedError, 'The authorization endpoint is not used in this strategy')
13
+ raise(NotImplementedError, "The authorization endpoint is not used in this strategy")
14
14
  end
15
15
 
16
16
  # Retrieve an access token given the specified client.
@@ -18,8 +18,8 @@ module OAuth2
18
18
  # @param [Hash] params additional params
19
19
  # @param [Hash] opts options
20
20
  def get_token(params = {}, opts = {})
21
- params = params.merge('grant_type' => 'client_credentials')
22
- @client.get_token(params, opts.merge('refresh_token' => nil))
21
+ params = params.merge("grant_type" => "client_credentials")
22
+ @client.get_token(params, opts)
23
23
  end
24
24
  end
25
25
  end
@@ -4,19 +4,28 @@ module OAuth2
4
4
  module Strategy
5
5
  # The Implicit Strategy
6
6
  #
7
+ # IMPORTANT (OAuth 2.1): The Implicit grant (response_type=token) is omitted from the OAuth 2.1 draft specification.
8
+ # It remains here for backward compatibility with OAuth 2.0 providers. Prefer the Authorization Code flow with PKCE.
9
+ #
10
+ # References:
11
+ # - OAuth 2.1 draft: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13
12
+ # - Why drop implicit: https://aaronparecki.com/2019/12/12/21/its-time-for-oauth-2-dot-1
13
+ # - Background: https://fusionauth.io/learn/expert-advice/oauth/differences-between-oauth-2-oauth-2-1/
14
+ #
7
15
  # @see http://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-26#section-4.2
8
16
  class Implicit < Base
9
17
  # The required query parameters for the authorize URL
10
18
  #
11
19
  # @param [Hash] params additional query parameters
12
20
  def authorize_params(params = {})
13
- params.merge('response_type' => 'token', 'client_id' => @client.id)
21
+ params.merge("response_type" => "token", "client_id" => @client.id)
14
22
  end
15
23
 
16
24
  # The authorization URL endpoint of the provider
17
25
  #
18
26
  # @param [Hash] params additional query parameters for the URL
19
27
  def authorize_url(params = {})
28
+ assert_valid_params(params)
20
29
  @client.authorize_url(authorize_params.merge(params))
21
30
  end
22
31
 
@@ -24,7 +33,13 @@ module OAuth2
24
33
  #
25
34
  # @raise [NotImplementedError]
26
35
  def get_token(*)
27
- raise(NotImplementedError, 'The token is accessed differently in this strategy')
36
+ raise(NotImplementedError, "The token is accessed differently in this strategy")
37
+ end
38
+
39
+ private
40
+
41
+ def assert_valid_params(params)
42
+ raise(ArgumentError, "client_secret is not allowed in authorize URL query params") if params.key?(:client_secret) || params.key?("client_secret")
28
43
  end
29
44
  end
30
45
  end