oauth2 1.4.7 → 2.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +811 -76
  4. data/CITATION.cff +20 -0
  5. data/CODE_OF_CONDUCT.md +24 -23
  6. data/CONTRIBUTING.md +221 -0
  7. data/FUNDING.md +74 -0
  8. data/IRP.md +107 -0
  9. data/{LICENSE → LICENSE.txt} +2 -2
  10. data/OIDC.md +167 -0
  11. data/README.md +1468 -166
  12. data/REEK +2 -0
  13. data/RUBOCOP.md +71 -0
  14. data/SECURITY.md +24 -0
  15. data/THREAT_MODEL.md +94 -0
  16. data/lib/oauth2/access_token.rb +276 -40
  17. data/lib/oauth2/auth_sanitizer.rb +36 -0
  18. data/lib/oauth2/authenticator.rb +51 -10
  19. data/lib/oauth2/client.rb +444 -124
  20. data/lib/oauth2/error.rb +63 -24
  21. data/lib/oauth2/filtered_attributes.rb +10 -0
  22. data/lib/oauth2/response.rb +138 -43
  23. data/lib/oauth2/strategy/assertion.rb +71 -41
  24. data/lib/oauth2/strategy/auth_code.rb +28 -5
  25. data/lib/oauth2/strategy/base.rb +2 -0
  26. data/lib/oauth2/strategy/client_credentials.rb +6 -4
  27. data/lib/oauth2/strategy/implicit.rb +20 -3
  28. data/lib/oauth2/strategy/password.rb +17 -5
  29. data/lib/oauth2/version.rb +2 -59
  30. data/lib/oauth2.rb +103 -12
  31. data/sig/oauth2/access_token.rbs +25 -0
  32. data/sig/oauth2/authenticator.rbs +22 -0
  33. data/sig/oauth2/client.rbs +52 -0
  34. data/sig/oauth2/error.rbs +8 -0
  35. data/sig/oauth2/filtered_attributes.rbs +11 -0
  36. data/sig/oauth2/response.rbs +18 -0
  37. data/sig/oauth2/sanitized_logger.rbs +32 -0
  38. data/sig/oauth2/strategy.rbs +34 -0
  39. data/sig/oauth2/thing_filter.rbs +10 -0
  40. data/sig/oauth2/version.rbs +5 -0
  41. data/sig/oauth2.rbs +9 -0
  42. data.tar.gz.sig +0 -0
  43. metadata +293 -102
  44. metadata.gz.sig +4 -0
  45. data/lib/oauth2/mac_token.rb +0 -130
  46. data/spec/helper.rb +0 -37
  47. data/spec/oauth2/access_token_spec.rb +0 -216
  48. data/spec/oauth2/authenticator_spec.rb +0 -84
  49. data/spec/oauth2/client_spec.rb +0 -506
  50. data/spec/oauth2/mac_token_spec.rb +0 -117
  51. data/spec/oauth2/response_spec.rb +0 -90
  52. data/spec/oauth2/strategy/assertion_spec.rb +0 -58
  53. data/spec/oauth2/strategy/auth_code_spec.rb +0 -107
  54. data/spec/oauth2/strategy/base_spec.rb +0 -5
  55. data/spec/oauth2/strategy/client_credentials_spec.rb +0 -69
  56. data/spec/oauth2/strategy/implicit_spec.rb +0 -26
  57. data/spec/oauth2/strategy/password_spec.rb +0 -55
  58. data/spec/oauth2/version_spec.rb +0 -23
data/lib/oauth2/error.rb CHANGED
@@ -1,40 +1,79 @@
1
+ # frozen_string_literal: true
2
+
1
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.
2
8
  class Error < StandardError
3
- 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
4
14
 
5
- # standard error values include:
6
- # :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
7
18
  def initialize(response)
8
- response.error = self
9
19
  @response = response
10
-
11
- if response.parsed.is_a?(Hash)
12
- @code = response.parsed['error']
13
- @description = response.parsed['error_description']
14
- error_description = "#{@code}: #{@description}"
20
+ @code = nil
21
+ @description = nil
22
+ if response.respond_to?(:parsed)
23
+ if response.parsed.is_a?(Hash)
24
+ @code = response.parsed["error"]
25
+ @description = response.parsed["error_description"]
26
+ end
27
+ elsif response.is_a?(Hash)
28
+ @code = response["error"]
29
+ @description = response["error_description"]
15
30
  end
16
-
17
- super(error_message(response.body, :error_description => error_description))
31
+ @body = if response.respond_to?(:body)
32
+ response.body
33
+ else
34
+ @response
35
+ end
36
+ message_opts = parse_error_description(@code, @description)
37
+ super(error_message(@body, message_opts))
18
38
  end
19
39
 
20
- # Makes a error message
21
- # @param [String] response_body response body of request
22
- # @param [String] opts :error_description error description to show first line
40
+ private
41
+
42
+ # Builds a multi-line error message including description and raw body.
43
+ #
44
+ # @param [String, #encode] response_body Response body content
45
+ # @param [Hash] opts Options including :error_description
46
+ # @return [String] Message suitable for StandardError
23
47
  def error_message(response_body, opts = {})
24
- message = []
48
+ lines = []
25
49
 
26
- opts[:error_description] && message << opts[:error_description]
50
+ lines << opts[:error_description] if opts[:error_description]
51
+
52
+ error_string = if response_body.respond_to?(:encode) && opts[:error_description].respond_to?(:encoding)
53
+ script_encoding = opts[:error_description].encoding
54
+ response_body.encode(script_encoding, invalid: :replace, undef: :replace)
55
+ else
56
+ response_body
57
+ end
58
+
59
+ lines << error_string
60
+
61
+ lines.join("\n")
62
+ end
27
63
 
28
- error_message = if opts[:error_description] && opts[:error_description].respond_to?(:encoding)
29
- script_encoding = opts[:error_description].encoding
30
- response_body.encode(script_encoding, :invalid => :replace, :undef => :replace)
31
- else
32
- response_body
33
- end
64
+ # Formats the OAuth2 error code and description into a single string.
65
+ #
66
+ # @param [String, nil] code OAuth2 error code
67
+ # @param [String, nil] description OAuth2 error description
68
+ # @return [Hash] Options hash containing :error_description when present
69
+ def parse_error_description(code, description)
70
+ return {} unless code || description
34
71
 
35
- message << error_message
72
+ error_description = ""
73
+ error_description += "#{code}: " if code
74
+ error_description += description if description
36
75
 
37
- message.join("\n")
76
+ {error_description: error_description}
38
77
  end
39
78
  end
40
79
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OAuth2
4
+ # Permanent alias for {OAuth2::AUTH_SANITIZER::FilteredAttributes}.
5
+ #
6
+ # This constant is intentionally kept in the `OAuth2` namespace because it
7
+ # was part of the public API before the implementation was extracted into the
8
+ # `auth-sanitizer` gem. It will **not** be deprecated or removed.
9
+ FilteredAttributes = OAuth2::AUTH_SANITIZER::FilteredAttributes
10
+ end
@@ -1,91 +1,186 @@
1
- require 'multi_json'
2
- require 'multi_xml'
3
- require 'rack'
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "multi_xml"
5
+ require "rack"
4
6
 
5
7
  module OAuth2
6
- # 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
7
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
8
23
  attr_reader :response
9
- attr_accessor :error, :options
10
24
 
11
- # Procs that, when called, will parse a response body according
12
- # 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
13
32
  @@parsers = {
14
- :json => lambda { |body| MultiJson.load(body) rescue body }, # rubocop:disable Style/RescueModifier
15
- :query => lambda { |body| Rack::Utils.parse_query(body) },
16
- :text => lambda { |body| body },
33
+ query: ->(body) { Rack::Utils.parse_query(body) },
34
+ text: ->(body) { body },
17
35
  }
18
36
 
19
- # 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
20
41
  @@content_types = {
21
- 'application/json' => :json,
22
- 'text/javascript' => :json,
23
- 'application/x-www-form-urlencoded' => :query,
24
- 'text/plain' => :text,
42
+ "application/x-www-form-urlencoded" => :query,
43
+ "text/plain" => :text,
25
44
  }
26
45
 
27
- # Adds a new content type parser.
28
- #
29
- # @param [Symbol] key A descriptive symbol key such as :json or :query.
30
- # @param [Array] mime_types One or more mime types to which this parser applies.
31
- # @yield [String] A block returning parsed content.
32
- def self.register_parser(key, mime_types, &block)
33
- key = key.to_sym
34
- @@parsers[key] = block
35
- Array(mime_types).each do |mime_type|
36
- @@content_types[mime_type] = key
46
+ class << self
47
+ # Adds a new content type parser.
48
+ #
49
+ # @param [Symbol] key A descriptive symbol key such as :json or :query
50
+ # @param [Array<String>, String] mime_types One or more mime types to which this parser applies
51
+ # @yield [String] Block that will be called to parse the response body
52
+ # @yieldparam [String] body The response body to parse
53
+ # @return [void]
54
+ def register_parser(key, mime_types, &block)
55
+ key = key.to_sym
56
+ @@parsers[key] = block
57
+ Array(mime_types).each do |mime_type|
58
+ @@content_types[mime_type] = key
59
+ end
37
60
  end
38
61
  end
39
62
 
40
63
  # Initializes a Response instance
41
64
  #
42
65
  # @param [Faraday::Response] response The Faraday response instance
43
- # @param [Hash] opts options in which to initialize the instance
44
- # @option opts [Symbol] :parse (:automatic) how to parse the response body. one of :query (for x-www-form-urlencoded),
45
- # :json, or :automatic (determined by Content-Type response header)
46
- def initialize(response, opts = {})
66
+ # @param [Symbol] parse (:automatic) How to parse the response body
67
+ # @param [Boolean] snaky (true) Whether to convert parsed response to snake_case using SnakyHash
68
+ # @param [Class, nil] snaky_hash_klass (nil) Custom class for snake_case hash conversion
69
+ # @param [Hash] options Additional options for the response
70
+ # @option options [Symbol] :parse (:automatic) Parse strategy (:query, :json, or :automatic)
71
+ # @option options [Boolean] :snaky (true) Enable/disable snake_case conversion
72
+ # @option options [Class] :snaky_hash_klass (SnakyHash::StringKeyed) Class to use for hash conversion
73
+ # @return [OAuth2::Response] The new Response instance
74
+ def initialize(response, parse: :automatic, snaky: true, snaky_hash_klass: nil, **options)
47
75
  @response = response
48
- @options = {:parse => :automatic}.merge(opts)
76
+ @options = {
77
+ parse: parse,
78
+ snaky: snaky,
79
+ snaky_hash_klass: snaky_hash_klass,
80
+ }.merge(options)
49
81
  end
50
82
 
51
83
  # The HTTP response headers
84
+ #
85
+ # @return [Hash] The response headers
52
86
  def headers
53
87
  response.headers
54
88
  end
55
89
 
56
90
  # The HTTP response status code
91
+ #
92
+ # @return [Integer] The response status code
57
93
  def status
58
94
  response.status
59
95
  end
60
96
 
61
97
  # The HTTP response body
98
+ #
99
+ # @return [String] The response body or empty string if nil
62
100
  def body
63
- response.body || ''
101
+ response.body || ""
64
102
  end
65
103
 
66
- # The parsed response body.
67
- # Will attempt to parse application/x-www-form-urlencoded and
68
- # application/json Content-Type response bodies
104
+ # The parsed response body
105
+ #
106
+ # @return [Object, SnakyHash::StringKeyed] The parsed response body
107
+ # @return [nil] If no parser is available
69
108
  def parsed
70
- return nil unless @@parsers.key?(parser)
109
+ return @parsed if defined?(@parsed)
110
+
111
+ @parsed =
112
+ if parser.respond_to?(:call)
113
+ case parser.arity
114
+ when 0
115
+ parser.call
116
+ when 1
117
+ parser.call(body)
118
+ else
119
+ parser.call(body, response)
120
+ end
121
+ end
122
+
123
+ if options[:snaky] && @parsed.is_a?(Hash)
124
+ hash_klass = options[:snaky_hash_klass] || DEFAULT_OPTIONS[:snaky_hash_klass]
125
+ @parsed = hash_klass[@parsed]
126
+ end
71
127
 
72
- @parsed ||= @@parsers[parser].call(body)
128
+ @parsed
73
129
  end
74
130
 
75
- # Attempts to determine the content type of the response.
131
+ # Determines the content type of the response
132
+ #
133
+ # @return [String, nil] The content type or nil if headers are not present
76
134
  def content_type
77
- ((response.headers.values_at('content-type', 'Content-Type').compact.first || '').split(';').first || '').strip
135
+ return unless response.headers
136
+
137
+ ((response.headers.values_at("content-type", "Content-Type").compact.first || "").split(";").first || "").strip.downcase
78
138
  end
79
139
 
80
- # Determines the parser that will be used to supply the content of #parsed
140
+ # Determines the parser to be used for the response body
141
+ #
142
+ # @note The parser can be supplied as the +:parse+ option in the form of a Proc
143
+ # (or other Object responding to #call) or a Symbol. In the latter case,
144
+ # the actual parser will be looked up in {@@parsers} by the supplied Symbol.
145
+ #
146
+ # @note If no +:parse+ option is supplied, the lookup Symbol will be determined
147
+ # by looking up {#content_type} in {@@content_types}.
148
+ #
149
+ # @note If {#parser} is a Proc, it will be called with no arguments, just
150
+ # {#body}, or {#body} and {#response}, depending on the Proc's arity.
151
+ #
152
+ # @return [Proc, #call] The parser proc or callable object
153
+ # @return [nil] If no suitable parser is found
81
154
  def parser
82
- return options[:parse].to_sym if @@parsers.key?(options[:parse])
155
+ return @parser if defined?(@parser)
83
156
 
84
- @@content_types[content_type]
157
+ @parser =
158
+ if options[:parse].respond_to?(:call)
159
+ options[:parse]
160
+ elsif options[:parse]
161
+ @@parsers[options[:parse].to_sym]
162
+ end
163
+
164
+ @parser ||= @@parsers[@@content_types[content_type]]
85
165
  end
86
166
  end
87
167
  end
88
168
 
89
- OAuth2::Response.register_parser(:xml, ['text/xml', 'application/rss+xml', 'application/rdf+xml', 'application/atom+xml']) do |body|
90
- MultiXml.parse(body) rescue body # rubocop:disable Style/RescueModifier
169
+ # Register XML parser
170
+ # @api private
171
+ OAuth2::Response.register_parser(:xml, ["text/xml", "application/rss+xml", "application/rdf+xml", "application/atom+xml", "application/xml"]) do |body|
172
+ next body unless body.respond_to?(:to_str)
173
+
174
+ MultiXml.parse(body)
175
+ end
176
+
177
+ # Register JSON parser
178
+ # @api private
179
+ 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|
180
+ next body unless body.respond_to?(:to_str)
181
+
182
+ body = body.dup.force_encoding(Encoding::ASCII_8BIT) if body.respond_to?(:force_encoding)
183
+ next body if body.respond_to?(:empty?) && body.empty?
184
+
185
+ JSON.parse(body)
91
186
  end
@@ -1,22 +1,31 @@
1
- require 'jwt'
1
+ # frozen_string_literal: true
2
+
3
+ require "jwt"
2
4
 
3
5
  module OAuth2
4
6
  module Strategy
5
7
  # The Client Assertion Strategy
6
8
  #
7
- # @see http://tools.ietf.org/html/draft-ietf-oauth-v2-10#section-4.1.3
9
+ # @see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-10#section-4.1.3
8
10
  #
9
11
  # Sample usage:
10
12
  # client = OAuth2::Client.new(client_id, client_secret,
11
- # :site => 'http://localhost:8080')
13
+ # :site => 'http://localhost:8080',
14
+ # :auth_scheme => :request_body)
15
+ #
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
+ # }
12
22
  #
13
- # params = {:hmac_secret => "some secret",
14
- # # or :private_key => "private key string",
15
- # :iss => "http://localhost:3001",
16
- # :prn => "me@here.com",
17
- # :exp => Time.now.utc.to_i + 3600}
23
+ # encoding = {
24
+ # :algorithm => 'HS256',
25
+ # :key => 'secret_key',
26
+ # }
18
27
  #
19
- # access = client.assertion.get_token(params)
28
+ # access = client.assertion.get_token(claim_set, encoding)
20
29
  # access.token # actual access_token string
21
30
  # access.get("/api/stuff") # making api calls with access token in header
22
31
  #
@@ -25,50 +34,71 @@ module OAuth2
25
34
  #
26
35
  # @raise [NotImplementedError]
27
36
  def authorize_url
28
- raise(NotImplementedError, 'The authorization endpoint is not used in this strategy')
37
+ raise(NotImplementedError, "The authorization endpoint is not used in this strategy")
29
38
  end
30
39
 
31
40
  # Retrieve an access token given the specified client.
32
41
  #
33
- # @param [Hash] params assertion params
34
- # 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
35
63
  #
36
- # params :hmac_secret, secret string.
37
- # 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
38
67
  #
39
- # params :iss, issuer
40
- # params :aud, audience, optional
41
- # params :prn, principal, current user
42
- # 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')})`
43
71
  #
44
- # @param [Hash] opts options
45
- def get_token(params = {}, opts = {})
46
- hash = build_request(params)
47
- @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)
48
84
  end
49
85
 
50
- def build_request(params)
51
- assertion = build_assertion(params)
86
+ private
87
+
88
+ def build_request(assertion, request_opts = {})
52
89
  {
53
- :grant_type => 'assertion',
54
- :assertion_type => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
55
- :assertion => assertion,
56
- :scope => params[:scope],
57
- }
90
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
91
+ assertion: assertion,
92
+ }.merge(request_opts)
58
93
  end
59
94
 
60
- def build_assertion(params)
61
- claims = {
62
- :iss => params[:iss],
63
- :aud => params[:aud],
64
- :prn => params[:prn],
65
- :exp => params[:exp],
66
- }
67
- if params[:hmac_secret]
68
- JWT.encode(claims, params[:hmac_secret], 'HS256')
69
- elsif params[:private_key]
70
- JWT.encode(claims, params[:private_key], 'RS256')
71
- 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)
72
102
  end
73
103
  end
74
104
  end
@@ -1,20 +1,33 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OAuth2
2
4
  module Strategy
3
5
  # The Authorization Code Strategy
4
6
  #
5
- # @see http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.1
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
+ #
17
+ # @see http://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-15#section-4.1
6
18
  class AuthCode < Base
7
19
  # The required query parameters for the authorize URL
8
20
  #
9
21
  # @param [Hash] params additional query parameters
10
22
  def authorize_params(params = {})
11
- params.merge('response_type' => 'code', 'client_id' => @client.id)
23
+ params.merge("response_type" => "code", "client_id" => @client.id)
12
24
  end
13
25
 
14
26
  # The authorization URL endpoint of the provider
15
27
  #
16
28
  # @param [Hash] params additional query parameters for the URL
17
29
  def authorize_url(params = {})
30
+ assert_valid_params(params)
18
31
  @client.authorize_url(authorize_params.merge(params))
19
32
  end
20
33
 
@@ -22,12 +35,22 @@ module OAuth2
22
35
  #
23
36
  # @param [String] code The Authorization Code value
24
37
  # @param [Hash] params additional params
25
- # @param [Hash] opts options
38
+ # @param [Hash] opts access_token_opts, @see Client#get_token
26
39
  # @note that you must also provide a :redirect_uri with most OAuth 2.0 providers
27
40
  def get_token(code, params = {}, opts = {})
28
- 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
29
51
 
30
- @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")
31
54
  end
32
55
  end
33
56
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OAuth2
2
4
  module Strategy
3
5
  class Base
@@ -1,14 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OAuth2
2
4
  module Strategy
3
5
  # The Client Credentials Strategy
4
6
  #
5
- # @see http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.4
7
+ # @see http://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-15#section-4.4
6
8
  class ClientCredentials < Base
7
9
  # Not used for this strategy
8
10
  #
9
11
  # @raise [NotImplementedError]
10
12
  def authorize_url
11
- raise(NotImplementedError, 'The authorization endpoint is not used in this strategy')
13
+ raise(NotImplementedError, "The authorization endpoint is not used in this strategy")
12
14
  end
13
15
 
14
16
  # Retrieve an access token given the specified client.
@@ -16,8 +18,8 @@ module OAuth2
16
18
  # @param [Hash] params additional params
17
19
  # @param [Hash] opts options
18
20
  def get_token(params = {}, opts = {})
19
- params = params.merge('grant_type' => 'client_credentials')
20
- @client.get_token(params, opts.merge('refresh_token' => nil))
21
+ params = params.merge("grant_type" => "client_credentials")
22
+ @client.get_token(params, opts)
21
23
  end
22
24
  end
23
25
  end
@@ -1,20 +1,31 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OAuth2
2
4
  module Strategy
3
5
  # The Implicit Strategy
4
6
  #
5
- # @see http://tools.ietf.org/html/draft-ietf-oauth-v2-26#section-4.2
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
+ #
15
+ # @see http://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-26#section-4.2
6
16
  class Implicit < Base
7
17
  # The required query parameters for the authorize URL
8
18
  #
9
19
  # @param [Hash] params additional query parameters
10
20
  def authorize_params(params = {})
11
- params.merge('response_type' => 'token', 'client_id' => @client.id)
21
+ params.merge("response_type" => "token", "client_id" => @client.id)
12
22
  end
13
23
 
14
24
  # The authorization URL endpoint of the provider
15
25
  #
16
26
  # @param [Hash] params additional query parameters for the URL
17
27
  def authorize_url(params = {})
28
+ assert_valid_params(params)
18
29
  @client.authorize_url(authorize_params.merge(params))
19
30
  end
20
31
 
@@ -22,7 +33,13 @@ module OAuth2
22
33
  #
23
34
  # @raise [NotImplementedError]
24
35
  def get_token(*)
25
- 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")
26
43
  end
27
44
  end
28
45
  end