diff --git a/.envrc b/.envrc index db2e5864..83123050 100644 --- a/.envrc +++ b/.envrc @@ -19,7 +19,7 @@ export K_SOUP_COV_DO=true # Means you want code coverage # Available formats are html, xml, rcov, lcov, json, tty export K_SOUP_COV_COMMAND_NAME="RSpec Coverage" export K_SOUP_COV_FORMATTERS="html,tty" -export K_SOUP_COV_MIN_BRANCH=99 # Means you want to enforce X% branch coverage +export K_SOUP_COV_MIN_BRANCH=100 # Means you want to enforce X% branch coverage export K_SOUP_COV_MIN_LINE=100 # Means you want to enforce X% line coverage export K_SOUP_COV_MIN_HARD=true # Means you want the build to fail if the coverage thresholds are not met export K_SOUP_COV_MULTI_FORMATTERS=true diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 796aa073..b01c457f 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,8 +1,8 @@ name: Test Coverage env: - K_SOUP_COV_MIN_BRANCH: 98 - K_SOUP_COV_MIN_LINE: 98 + K_SOUP_COV_MIN_BRANCH: 100 + K_SOUP_COV_MIN_LINE: 100 K_SOUP_COV_MIN_HARD: true K_SOUP_COV_FORMATTERS: "html,rcov,lcov,json,tty" K_SOUP_COV_DO: true diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index de040959..c6a03113 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -7,8 +7,8 @@ variables: K_SOUP_COV_DEBUG: true K_SOUP_COV_DO: true K_SOUP_COV_HARD: true - K_SOUP_COV_MIN_BRANCH: 98 - K_SOUP_COV_MIN_LINE: 98 + K_SOUP_COV_MIN_BRANCH: 100 + K_SOUP_COV_MIN_LINE: 100 K_SOUP_COV_VERBOSE: true K_SOUP_COV_FORMATTERS: "html,xml,rcov,lcov,json,tty" K_SOUP_COV_MULTI_FORMATTERS: true diff --git a/.rubocop_gradual.lock b/.rubocop_gradual.lock index 379f4317..6c2fc8dd 100644 --- a/.rubocop_gradual.lock +++ b/.rubocop_gradual.lock @@ -18,7 +18,7 @@ [9, 9, 25, "ThreadSafety/ClassInstanceVariable: Avoid class instance variables.", 2012823020], [13, 9, 25, "ThreadSafety/ClassInstanceVariable: Avoid class instance variables.", 2012823020] ], - "lib/oauth2/response.rb:877496664": [ + "lib/oauth2/response.rb:355921218": [ [35, 5, 204, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 996912427] ], "oauth2.gemspec:290828046": [ @@ -31,11 +31,11 @@ [130, 3, 52, "Gemspec/DependencyVersion: Dependency version specification is required.", 3163430777], [131, 3, 48, "Gemspec/DependencyVersion: Dependency version specification is required.", 425065368] ], - "spec/oauth2/access_token_spec.rb:1576666213": [ + "spec/oauth2/access_token_spec.rb:388877639": [ [3, 1, 34, "RSpec/SpecFilePathFormat: Spec path should end with `o_auth2/access_token*_spec.rb`.", 1972107547], - [590, 13, 25, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 770233088], - [660, 9, 101, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 3022740639], - [664, 9, 79, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 2507338967] + [594, 13, 25, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 770233088], + [664, 9, 101, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 3022740639], + [668, 9, 79, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 2507338967] ], "spec/oauth2/authenticator_spec.rb:853320290": [ [3, 1, 36, "RSpec/SpecFilePathFormat: Spec path should end with `o_auth2/authenticator*_spec.rb`.", 819808017], @@ -44,7 +44,7 @@ [69, 15, 38, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1480816240], [79, 13, 23, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 2314399065] ], - "spec/oauth2/client_spec.rb:3773709445": [ + "spec/oauth2/client_spec.rb:824695973": [ [6, 1, 29, "RSpec/SpecFilePathFormat: Spec path should end with `o_auth2/client*_spec.rb`.", 439549885], [174, 7, 492, "RSpec/NoExpectationExample: No expectation found in this example.", 1272021224], [193, 7, 592, "RSpec/NoExpectationExample: No expectation found in this example.", 3428877205], @@ -52,18 +52,18 @@ [221, 15, 20, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1276531672], [236, 15, 43, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 1383956904], [251, 15, 43, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 3376202107], - [590, 5, 360, "RSpec/NoExpectationExample: No expectation found in this example.", 536201463], - [599, 5, 461, "RSpec/NoExpectationExample: No expectation found in this example.", 3392600621], - [610, 5, 340, "RSpec/NoExpectationExample: No expectation found in this example.", 244592251], - [655, 63, 2, "RSpec/BeEq: Prefer `be` over `eq`.", 5860785], - [700, 11, 99, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 3084776886], - [704, 11, 82, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 1524553529], - [712, 7, 89, "RSpec/NoExpectationExample: No expectation found in this example.", 4609419], - [800, 11, 99, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 3084776886], - [804, 11, 82, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 1524553529], - [884, 17, 12, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 664794325], - [909, 5, 459, "RSpec/NoExpectationExample: No expectation found in this example.", 2216851076], - [919, 7, 450, "RSpec/NoExpectationExample: No expectation found in this example.", 2619808549] + [869, 5, 360, "RSpec/NoExpectationExample: No expectation found in this example.", 536201463], + [878, 5, 461, "RSpec/NoExpectationExample: No expectation found in this example.", 3392600621], + [889, 5, 340, "RSpec/NoExpectationExample: No expectation found in this example.", 244592251], + [934, 63, 2, "RSpec/BeEq: Prefer `be` over `eq`.", 5860785], + [979, 11, 99, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 3084776886], + [983, 11, 82, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 1524553529], + [991, 7, 89, "RSpec/NoExpectationExample: No expectation found in this example.", 4609419], + [1079, 11, 99, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 3084776886], + [1083, 11, 82, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 1524553529], + [1163, 17, 12, "RSpec/ContextWording: Context description should match /^when\\b/, /^with\\b/, or /^without\\b/.", 664794325], + [1188, 5, 459, "RSpec/NoExpectationExample: No expectation found in this example.", 2216851076], + [1198, 7, 450, "RSpec/NoExpectationExample: No expectation found in this example.", 2619808549] ], "spec/oauth2/error_spec.rb:1209122273": [ [23, 1, 28, "RSpec/SpecFilePathFormat: Spec path should end with `o_auth2/error*_spec.rb`.", 3385870076], diff --git a/Rakefile b/Rakefile index cdd18938..ac15c136 100644 --- a/Rakefile +++ b/Rakefile @@ -42,7 +42,8 @@ begin require "rubocop/lts" Rubocop::Lts.install_tasks - defaults << "rubocop_gradual" + # Make autocorrect the default rubocop task + defaults << "rubocop_gradual:autocorrect" rescue LoadError desc("(stub) rubocop_gradual is unavailable") task(:rubocop_gradual) do diff --git a/lib/oauth2/access_token.rb b/lib/oauth2/access_token.rb index 04a2049d..5cdd789b 100644 --- a/lib/oauth2/access_token.rb +++ b/lib/oauth2/access_token.rb @@ -15,16 +15,47 @@ class AccessToken # rubocop:disable Metrics/ClassLength class << self # Initializes an AccessToken from a Hash # - # @param [Client] client the OAuth2::Client instance - # @param [Hash] hash a hash of AccessToken property values - # @option hash [String, Symbol] 'access_token', 'id_token', 'token', :access_token, :id_token, or :token the access token - # @return [AccessToken] the initialized AccessToken + # @param [OAuth2::Client] client the OAuth2::Client instance + # @param [Hash] hash a hash containing the token and other properties + # @option hash [String] 'access_token' the access token value + # @option hash [String] 'id_token' alternative key for the access token value + # @option hash [String] 'token' alternative key for the access token value + # @option hash [String] 'refresh_token' (optional) the refresh token value + # @option hash [Integer, String] 'expires_in' (optional) number of seconds until token expires + # @option hash [Integer, String] 'expires_at' (optional) epoch time in seconds when token expires + # @option hash [Integer, String] 'expires_latency' (optional) seconds to reduce token validity by + # + # @return [OAuth2::AccessToken] the initialized AccessToken + # + # @note The method will use the first found token key in the following order: + # 'access_token', 'id_token', 'token' (or their symbolic versions) + # @note If multiple token keys are present, a warning will be issued unless + # OAuth2.config.silence_extra_tokens_warning is true + # @note For "soon-to-expire"/"clock-skew" functionality see the `:expires_latency` option. + # @mote If snaky key conversion is being used, token_name needs to match the converted key. + # + # @example + # hash = { 'access_token' => 'token_value', 'refresh_token' => 'refresh_value' } + # access_token = OAuth2::AccessToken.from_hash(client, hash) def from_hash(client, hash) fresh = hash.dup - supported_keys = TOKEN_KEY_LOOKUP & fresh.keys - key = supported_keys[0] - extra_tokens_warning(supported_keys, key) - token = fresh.delete(key) + # If token_name is present, then use that key name + if fresh.key?(:token_name) + key = fresh[:token_name] + if key.nil? || !fresh.key?(key) + warn(%[ +OAuth2::AccessToken#from_hash key mismatch. +Custom token_name (#{key}) does match any keys (#{fresh.keys}) +You may need to set `snaky: false`. See inline documentation for more info. + ]) + end + else + # Otherwise, if one of the supported default keys is present, use whichever has precedence + supported_keys = TOKEN_KEY_LOOKUP & fresh.keys + key = supported_keys[0] + extra_tokens_warning(supported_keys, key) + end + token = fresh.delete(key) || "" new(client, token, fresh) end @@ -50,6 +81,16 @@ def extra_tokens_warning(supported_keys, key) # Initialize an AccessToken # + # @note For "soon-to-expire"/"clock-skew" functionality see the `:expires_latency` option. + # @note If no token is provided, the AccessToken will be considered invalid. + # This is to prevent the possibility of a token being accidentally + # created with no token value. + # If you want to create an AccessToken with no token value, + # you can pass in an empty string or nil for the token value. + # If you want to create an AccessToken with no token value and + # no refresh token, you can pass in an empty string or nil for the + # token value and nil for the refresh token, and `raise_errors: false`. + # # @param [Client] client the OAuth2::Client instance # @param [String] token the Access Token value (optional, may not be used in refresh flows) # @param [Hash] opts the options to create the Access Token with @@ -62,10 +103,11 @@ def extra_tokens_warning(supported_keys, key) # @option opts [String] :header_format ('Bearer %s') the string format to use for the Authorization header # @option opts [String] :param_name ('access_token') the parameter name to use for transmission of the # Access Token value in :body or :query transmission mode + # @option opts [String] :token_name (nil) the name of the response parameter that identifies the access token + # When nil one of TOKEN_KEY_LOOKUP will be used def initialize(client, token, opts = {}) @client = client @token = token.to_s - opts = opts.dup %i[refresh_token expires_in expires_at expires_latency].each do |arg| instance_variable_set("@#{arg}", opts.delete(arg) || opts.delete(arg.to_s)) @@ -91,6 +133,8 @@ def initialize(client, token, opts = {}) header_format: opts.delete(:header_format) || "Bearer %s", param_name: opts.delete(:param_name) || "access_token", } + @options[:token_name] = opts.delete(:token_name) if opts.key?(:token_name) + @params = opts end @@ -139,9 +183,26 @@ def refresh(params = {}, access_token_opts = {}) # Convert AccessToken to a hash which can be used to rebuild itself with AccessToken.from_hash # + # @note Don't return expires_latency because it has already been deducted from expires_at + # # @return [Hash] a hash of AccessToken property values def to_hash - params.merge(access_token: token, refresh_token: refresh_token, expires_at: expires_at) + hsh = { + access_token: token, + refresh_token: refresh_token, + expires_at: expires_at, + mode: options[:mode], + header_format: options[:header_format], + param_name: options[:param_name], + } + hsh[:token_name] = options[:token_name] if options.key?(:token_name) + # TODO: Switch when dropping Ruby < 2.5 support + # params.transform_keys(&:to_sym) # Ruby 2.5 only + # Old Ruby transform_keys alternative: + sheesh = @params.each_with_object({}) { |(k, v), memo| + memo[k.to_sym] = v + } + sheesh.merge(hsh) end # Make a request with the Access Token diff --git a/lib/oauth2/client.rb b/lib/oauth2/client.rb index 4176cc25..88fb97fe 100644 --- a/lib/oauth2/client.rb +++ b/lib/oauth2/client.rb @@ -3,10 +3,14 @@ require "faraday" require "logger" +# :nocov: since coverage tracking only runs on the builds with Faraday v2 +# We do run builds on Faraday v0 (and v1!), so this code is actually covered! +# This is the only nocov in the whole project! if Faraday::Utils.respond_to?(:default_space_encoding) # This setting doesn't exist in faraday 0.x Faraday::Utils.default_space_encoding = "%20" end +# :nocov: module OAuth2 ConnectionError = Class.new(Faraday::ConnectionFailed) @@ -23,25 +27,22 @@ class Client # rubocop:disable Metrics/ClassLength attr_writer :connection filtered_attributes :secret - # Instantiate a new OAuth 2.0 client using the - # Client ID and Client Secret registered to your - # application. + # Initializes a new OAuth2::Client instance using the Client ID and Client Secret registered to your application. # # @param [String] client_id the client_id value # @param [String] client_secret the client_secret value - # @param [Hash] options the options to create the client with + # @param [Hash] options the options to configure the client # @option options [String] :site the OAuth2 provider site host - # @option options [String] :redirect_uri the absolute URI to the Redirection Endpoint for use in authorization grants and token exchange # @option options [String] :authorize_url ('/oauth/authorize') absolute or relative URL path to the Authorization endpoint # @option options [String] :token_url ('/oauth/token') absolute or relative URL path to the Token endpoint # @option options [Symbol] :token_method (:post) HTTP method to use to request token (:get, :post, :post_with_query_string) - # @option options [Symbol] :auth_scheme (:basic_auth) HTTP method to use to authorize request (:basic_auth or :request_body) - # @option options [Hash] :connection_opts ({}) Hash of connection options to pass to initialize Faraday with - # @option options [FixNum] :max_redirects (5) maximum number of redirects to follow - # @option options [Boolean] :raise_errors (true) whether or not to raise an OAuth2::Error on responses with 400+ status codes - # @option options [Logger] :logger (::Logger.new($stdout)) which logger to use when OAUTH_DEBUG is enabled - # @option options [Proc] :extract_access_token proc that takes the client and the response Hash and extracts the access token from the response (DEPRECATED) - # @option options [Class] :access_token_class [Class] class of access token for easier subclassing OAuth2::AccessToken, @version 2.0+ + # @option options [Symbol] :auth_scheme (:basic_auth) the authentication scheme (:basic_auth or :request_body) + # @option options [Hash] :connection_opts ({}) Hash of connection options to pass to initialize Faraday + # @option options [Boolean] :raise_errors (true) whether to raise an OAuth2::Error on responses with 400+ status codes + # @option options [Integer] :max_redirects (5) maximum number of redirects to follow + # @option options [Logger] :logger (::Logger.new($stdout)) Logger instance for HTTP request/response output; requires OAUTH_DEBUG to be true + # @option options [Class] :access_token_class (AccessToken) class to use for access tokens; you can subclass OAuth2::AccessToken, @version 2.0+ + # @option options [Hash] :ssl SSL options for Faraday # @yield [builder] The Faraday connection builder def initialize(client_id, client_secret, options = {}, &block) opts = options.dup @@ -113,8 +114,8 @@ def token_url(params = nil) # @option opts [Hash] :params additional query parameters for the URL of the request # @option opts [Hash, String] :body the body of the request # @option opts [Hash] :headers http request headers - # @option opts [Boolean] :raise_errors whether or not to raise an OAuth2::Error on 400+ status - # code response for this request. Will default to client option + # @option opts [Boolean] :raise_errors whether to raise an OAuth2::Error on 400+ status + # code response for this request. Overrides the client instance setting. # @option opts [Symbol] :parse @see Response::initialize # @option opts [true, false] :snaky (true) @see Response::initialize # @yield [req] @see Faraday::Connection#run_request @@ -155,15 +156,29 @@ def request(verb, url, opts = {}, &block) end end - # Initializes an AccessToken by making a request to the token endpoint + # Retrieves an access token from the token endpoint using the specified parameters # - # @param params [Hash] a Hash of params for the token endpoint, except: - # @option params [Symbol] :parse @see Response#initialize - # @option params [true, false] :snaky (true) @see Response#initialize - # @param access_token_opts [Hash] access token options, to pass to the AccessToken object - # @param extract_access_token [Proc] proc that extracts the access token from the response (DEPRECATED) - # @yield [req] @see Faraday::Connection#run_request - # @return [AccessToken] the initialized AccessToken + # @param [Hash] params a Hash of params for the token endpoint + # * params can include a 'headers' key with a Hash of request headers + # * params can include a 'parse' key with the Symbol name of response parsing strategy (default: :automatic) + # * params can include a 'snaky' key to control snake_case conversion (default: false) + # @param [Hash] access_token_opts options that will be passed to the AccessToken initialization + # @param [Proc] extract_access_token (deprecated) a proc that can extract the access token from the response + # @yield [opts] The block is passed the options being used to make the request + # @yieldparam [Hash] opts options being passed to the http library + # + # @return [AccessToken, nil] the initialized AccessToken instance, or nil if token extraction fails + # and raise_errors is false + # + # @note The extract_access_token parameter is deprecated and will be removed in oauth2 v3. + # Use access_token_class on initialization instead. + # + # @example + # client.get_token( + # 'grant_type' => 'authorization_code', + # 'code' => 'auth_code_value', + # 'headers' => {'Authorization' => 'Basic ...'} + # ) def get_token(params, access_token_opts = {}, extract_access_token = nil, &block) 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 extract_access_token ||= options[:extract_access_token] @@ -176,7 +191,7 @@ def get_token(params, access_token_opts = {}, extract_access_token = nil, &block } if options[:token_method] == :post - # NOTE: If proliferation of request types continues we should implement a parser solution for Request, + # NOTE: If proliferation of request types continues, we should implement a parser solution for Request, # just like we have with Response. request_opts[:body] = if headers["Content-Type"] == "application/json" params.to_json @@ -269,6 +284,20 @@ def redirection_params private + # Processes and transforms the input parameters for OAuth requests + # + # @param [Hash] params the input parameters to process + # @option params [Symbol, nil] :parse (:automatic) parsing strategy for the response + # @option params [Boolean] :snaky (true) whether to convert response keys to snake_case + # @option params [Hash] :headers HTTP headers for the request + # + # @return [Array<(Symbol, Boolean, Hash, Hash)>] Returns an array containing: + # - [Symbol, nil] parse strategy + # - [Boolean] snaky flag for response key transformation + # - [Hash] processed parameters + # - [Hash] HTTP headers + # + # @api private def parse_snaky_params_headers(params) params = params.map do |key, value| if RESERVED_PARAM_KEYS.include?(key) @@ -285,6 +314,26 @@ def parse_snaky_params_headers(params) [parse, snaky, params, headers] end + # Executes an HTTP request with error handling and response processing + # + # @param [Symbol] verb the HTTP method to use (:get, :post, :put, :delete) + # @param [String] url the URL for the request + # @param [Hash] opts the request options + # @option opts [Hash] :body the request body + # @option opts [Hash] :headers the request headers + # @option opts [Hash] :params the query parameters to append to the URL + # @option opts [Symbol, nil] :parse (:automatic) parsing strategy for the response + # @option opts [Boolean] :snaky (true) whether to convert response keys to snake_case + # + # @yield [req] gives access to the request object before sending + # @yieldparam [Faraday::Request] req the request object that can be modified + # + # @return [OAuth2::Response] the response wrapped in an OAuth2::Response object + # + # @raise [OAuth2::ConnectionError] when there's a network error + # @raise [OAuth2::TimeoutError] when the request times out + # + # @api private def execute_request(verb, url, opts = {}) url = connection.build_url(url).to_s @@ -312,6 +361,20 @@ def authenticator Authenticator.new(id, secret, options[:auth_scheme]) end + # Parses the OAuth response and builds an access token using legacy extraction method + # + # @deprecated Use {#parse_response} instead + # + # @param [OAuth2::Response] response the OAuth2::Response from the token endpoint + # @param [Hash] access_token_opts options to pass to the AccessToken initialization + # @param [Proc] extract_access_token proc to extract the access token from response + # + # @return [AccessToken, nil] the initialized AccessToken if successful, nil if extraction fails + # and raise_errors option is false + # + # @raise [OAuth2::Error] if response indicates an error and raise_errors option is true + # + # @api private def parse_response_legacy(response, access_token_opts, extract_access_token) access_token = build_access_token_legacy(response, access_token_opts, extract_access_token) @@ -325,6 +388,16 @@ def parse_response_legacy(response, access_token_opts, extract_access_token) nil end + # Parses the OAuth response and builds an access token using the configured access token class + # + # @param [OAuth2::Response] response the OAuth2::Response from the token endpoint + # @param [Hash] access_token_opts options to pass to the AccessToken initialization + # + # @return [AccessToken] the initialized AccessToken instance + # + # @raise [OAuth2::Error] if the response is empty/invalid and the raise_errors option is true + # + # @api private def parse_response(response, access_token_opts) access_token_class = options[:access_token_class] data = response.parsed @@ -339,18 +412,36 @@ def parse_response(response, access_token_opts) build_access_token(response, access_token_opts, access_token_class) end - # Builds the access token from the response of the HTTP call + # Creates an access token instance from response data using the specified token class # - # @return [AccessToken] the initialized AccessToken + # @param [OAuth2::Response] response the OAuth2::Response from the token endpoint + # @param [Hash] access_token_opts additional options to pass to the AccessToken initialization + # @param [Class] access_token_class the class that should be used to create access token instances + # + # @return [AccessToken] an initialized AccessToken instance with response data + # + # @note If the access token class responds to response=, the full response object will be set + # + # @api private def build_access_token(response, access_token_opts, access_token_class) access_token_class.from_hash(self, response.parsed.merge(access_token_opts)).tap do |access_token| access_token.response = response if access_token.respond_to?(:response=) end end - # Builds the access token from the response of the HTTP call with legacy extract_access_token + # Builds an access token using a legacy extraction proc + # + # @deprecated Use {#build_access_token} instead + # + # @param [OAuth2::Response] response the OAuth2::Response from the token endpoint + # @param [Hash] access_token_opts additional options to pass to the access token extraction + # @param [Proc] extract_access_token a proc that takes client and token hash as arguments + # and returns an access token instance + # + # @return [AccessToken, nil] the access token instance if extraction succeeds, + # nil if any error occurs during extraction # - # @return [AccessToken] the initialized AccessToken + # @api private def build_access_token_legacy(response, access_token_opts, extract_access_token) extract_access_token.call(self, response.parsed.merge(access_token_opts)) rescue StandardError diff --git a/lib/oauth2/response.rb b/lib/oauth2/response.rb index 7003bf20..ac8e11e6 100644 --- a/lib/oauth2/response.rb +++ b/lib/oauth2/response.rb @@ -90,7 +90,10 @@ def parsed end end - @parsed = SnakyHash::StringKeyed.new(@parsed) if options[:snaky] && @parsed.is_a?(Hash) + if options[:snaky] && @parsed.is_a?(Hash) + parsed = SnakyHash::StringKeyed.new(@parsed) + @parsed = parsed.to_h + end @parsed end diff --git a/spec/oauth2/access_token_spec.rb b/spec/oauth2/access_token_spec.rb index 8c1ef0a5..30119944 100644 --- a/spec/oauth2/access_token_spec.rb +++ b/spec/oauth2/access_token_spec.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true RSpec.describe OAuth2::AccessToken do - subject { described_class.new(client, token) } + subject { described_class.new(client, token, token_options) } let(:base_options) { {site: "https://api.example.com"} } + let(:token_options) { {} } let(:options) { {} } let(:token) { "monkey" } let(:refresh_body) { JSON.dump(access_token: "refreshed_foo", expires_in: 600, refresh_token: "refresh_bar") } @@ -27,11 +28,14 @@ let(:hash) do { - :access_token => token, - :id_token => "confusing bug here", - :refresh_token => "foobar", - :expires_at => Time.now.to_i + 200, - "foo" => "bar", + access_token: token, + id_token: "confusing bug here", + refresh_token: "foobar", + expires_at: Time.now.to_i + 200, + foo: "bar", + header_format: "Bearer %", + mode: :header, + param_name: "access_token", } end @@ -140,7 +144,7 @@ def assert_initialized_token(target) end context "with options" do - subject(:target) { described_class.new(client, token, **options) } + subject(:target) { described_class.new(client, token, options) } context "with body mode" do let(:mode) { :body } @@ -744,10 +748,35 @@ def self.contains_token?(hash) describe "#to_hash" do it "return a hash equal to the hash used to initialize access token" do - hash = {:access_token => token, :refresh_token => "foobar", :expires_at => Time.now.to_i + 200, "foo" => "bar"} + hash = { + access_token: token, + refresh_token: "foobar", + expires_at: Time.now.to_i + 200, + header_format: "Bearer %", + mode: :header, + param_name: "access_token", + foo: "bar", + } access_token = described_class.from_hash(client, hash.clone) expect(access_token.to_hash).to eq(hash) end + + context "with token_name" do + it "return a hash equal to the hash used to initialize access token" do + hash = { + access_token: "", + refresh_token: "foobar", + expires_at: Time.now.to_i + 200, + header_format: "Bearer %", + mode: :header, + param_name: "access_token", + token_name: "banana_face", + foo: "bar", + } + access_token = described_class.from_hash(client, hash.clone) + expect(access_token.to_hash).to eq(hash) + end + end end describe "#inspect" do diff --git a/spec/oauth2/client_spec.rb b/spec/oauth2/client_spec.rb index 59dedc4f..3918dc3b 100644 --- a/spec/oauth2/client_spec.rb +++ b/spec/oauth2/client_spec.rb @@ -488,6 +488,33 @@ it_behaves_like "failed connection handler" end end + + context "when snaky: true" do + subject(:response_body) do + response = instance.request(:post, "/reflect", **req_options) + response.body + end + + let(:req_options) { + { + headers: {"Content-Type" => "application/json"}, + body: {foo: "bar"}, + snaky: true, + } + } + + it "body a body" do + expect(response_body).to eq({foo: "bar"}) + end + + it "body is a standard hash" do + expect(response_body).to be_a(Hash) + end + + it "body is not a SnakyHash" do + expect(response_body).not_to be_a(SnakyHash) + end + end end describe "#get_token" do @@ -557,33 +584,285 @@ end end - context "when snaky is falsy, but response is snaky" do - it "returns a configured AccessToken" do + context "when snaky" do + subject(:token) do client = stubbed_client do |stub| stub.post("/oauth/token") do - [200, {"Content-Type" => "application/json"}, JSON.dump("access_token" => "the-token")] + [200, {"Content-Type" => "application/json"}, response_body] end end - token = client.get_token(snaky: false) - expect(token).to be_a OAuth2::AccessToken - expect(token.token).to eq("the-token") - expect(token.response.parsed.to_h).to eq("access_token" => "the-token") + client.get_token(params, access_token_opts) end - end - context "when snaky is falsy, but response is not snaky" do - it "returns a configured AccessToken" do - client = stubbed_client do |stub| - stub.post("/oauth/token") do - [200, {"Content-Type" => "application/json"}, JSON.dump("accessToken" => "the-token")] + let(:access_token_opts) { {} } + let(:response_body) { JSON.dump("access_token" => "the-token") } + + context "when falsy" do + let(:params) { {snaky: false} } + + context "when response is underscored" do + context "without token_name" do + it "returns a configured AccessToken" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed.to_h).to eq("access_token" => "the-token") + end + + it "parsed is a Hash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).to be_a(Hash) + end + + it "parsed is not a SnakyHash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).not_to be_a(SnakyHash) + end + end + + context "with token_name" do + let(:access_token_opts) { {token_name: "access_token"} } + + it "returns a configured AccessToken" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed.to_h).to eq("access_token" => "the-token") + end + + it "parsed is a Hash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).to be_a(Hash) + end + + it "parsed is not a SnakyHash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).not_to be_a(SnakyHash) + end + + context "with alternate token named" do + let(:access_token_opts) { {token_name: "banana_face"} } + let(:response_body) { JSON.dump("banana_face" => "the-token") } + + it "parsed is a Hash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).to be_a(Hash) + end + + it "parsed is not a SnakyHash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).not_to be_a(SnakyHash) + end + + it "returns a snake-cased key" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed.to_h).to eq("banana_face" => "the-token") + end + end end end - token = client.get_token({snaky: false}, {param_name: "accessToken"}) - expect(token).to be_a OAuth2::AccessToken - expect(token.token).to eq("the-token") - expect(token.response.parsed.to_h).to eq("accessToken" => "the-token") + context "when response is camelcased" do + let(:access_token_opts) { {token_name: "accessToken"} } + let(:response_body) { JSON.dump("accessToken" => "the-token") } + + context "without token_name" do + it "parsed is a Hash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).to be_a(Hash) + end + + it "parsed is not a SnakyHash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).not_to be_a(SnakyHash) + end + + it "returns a configured AccessToken" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed.to_h).to eq("accessToken" => "the-token") + end + end + + context "with token_name" do + let(:access_token_opts) { {token_name: "accessToken"} } + + it "returns a configured AccessToken" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed.to_h).to eq("accessToken" => "the-token") + end + + it "parsed is a Hash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).to be_a(Hash) + end + + it "parsed is not a SnakyHash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).not_to be_a(SnakyHash) + end + + context "with alternate token named" do + let(:access_token_opts) { {token_name: "bananaFace"} } + let(:response_body) { JSON.dump("bananaFace" => "the-token") } + + it "parsed is a Hash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).to be_a(Hash) + end + + it "parsed is not a SnakyHash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).not_to be_a(SnakyHash) + end + + it "returns a snake-cased key" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed.to_h).to eq("bananaFace" => "the-token") + end + end + end + end + end + + context "when truthy" do + let(:params) { {snaky: true} } + + context "when response is snake-cased" do + context "with token_name" do + let(:access_token_opts) { {token_name: "access_token"} } + + it "parsed is a Hash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).to be_a(Hash) + end + + it "parsed is not a SnakyHash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).not_to be_a(SnakyHash) + end + + it "returns a snake-cased key" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed.to_h).to eq("access_token" => "the-token") + end + + context "with alternate token named" do + let(:access_token_opts) { {token_name: "banana_face"} } + let(:response_body) { JSON.dump("banana_face" => "the-token") } + + it "parsed is a Hash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).to be_a(Hash) + end + + it "parsed is not a SnakyHash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).not_to be_a(SnakyHash) + end + + it "returns a snake-cased key" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed.to_h).to eq("banana_face" => "the-token") + end + end + end + + context "without token_name" do + it "returns a configured AccessToken" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed.to_h).to eq("access_token" => "the-token") + end + + it "parsed is a Hash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).to be_a(Hash) + end + + it "parsed is not a SnakyHash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).not_to be_a(SnakyHash) + end + end + end + + context "when response is camel-cased" do + let(:response_body) { JSON.dump("accessToken" => "the-token") } + + context "with token_name" do + let(:access_token_opts) { {token_name: "accessToken"} } + + it "raises an Error because snaky has renamed the key" do + block_is_expected.to raise_error(OAuth2::Error) + end + + context "with alternate snaky token named" do + let(:access_token_opts) { {token_name: "banana_butter_cake"} } + let(:response_body) { JSON.dump("banana-butterCake" => "the-token") } + + it "parsed is a Hash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).to be_a(Hash) + end + + it "parsed is not a SnakyHash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).not_to be_a(SnakyHash) + end + + it "returns a snake-cased key" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed.to_h).to eq("banana_butter_cake" => "the-token") + end + end + end + + context "without token_name" do + it "parsed is a Hash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).to be_a(Hash) + end + + it "parsed is not a SnakyHash" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed).not_to be_a(SnakyHash) + end + + it "returns a snake-cased key" do + expect(token).to be_a OAuth2::AccessToken + expect(token.token).to eq("the-token") + expect(token.response.parsed.to_h).to eq("access_token" => "the-token") + end + end + end end end