From 5655c602c738183682ecdc88d02df8e2e077d143 Mon Sep 17 00:00:00 2001 From: HoneyryderChuck Date: Sat, 20 May 2023 02:17:41 +0200 Subject: [PATCH] the oauth plugin --- lib/httpx/plugins/digest_authentication.rb | 2 +- lib/httpx/plugins/oauth.rb | 170 +++++++++++++++++++++ sig/chainable.rbs | 1 + sig/plugins/oauth.rbs | 54 +++++++ test/support/requests/plugins/oauth.rb | 2 +- 5 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 lib/httpx/plugins/oauth.rb create mode 100644 sig/plugins/oauth.rbs diff --git a/lib/httpx/plugins/digest_authentication.rb b/lib/httpx/plugins/digest_authentication.rb index eb684627..57dd743c 100644 --- a/lib/httpx/plugins/digest_authentication.rb +++ b/lib/httpx/plugins/digest_authentication.rb @@ -22,7 +22,7 @@ module HTTPX module OptionsMethods def option_digest(value) - raise TypeError, ":digest must be a Digest" unless value.is_a?(Authentication::Digest) + raise TypeError, ":digest must be a #{Authentication::Digest}" unless value.is_a?(Authentication::Digest) value end diff --git a/lib/httpx/plugins/oauth.rb b/lib/httpx/plugins/oauth.rb new file mode 100644 index 00000000..6422e57d --- /dev/null +++ b/lib/httpx/plugins/oauth.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +module HTTPX + module Plugins + # + # https://gitlab.com/os85/httpx/wikis/OAuth + # + module OAuth + class << self + def load_dependencies(_klass) + require_relative "authentication/basic" + end + end + + SUPPORTED_GRANT_TYPES = %w[client_credentials refresh_token].freeze + SUPPORTED_AUTH_METHODS = %w[client_secret_basic client_secret_post].freeze + + class OAuthSession + attr_reader :token_endpoint_auth_method, :grant_type, :client_id, :client_secret, :access_token, :refresh_token, :scope + + def initialize( + issuer:, + client_id:, + client_secret:, + access_token: nil, + refresh_token: nil, + scope: nil, + token_endpoint: nil, + response_type: nil, + grant_type: nil, + token_endpoint_auth_method: "client_secret_basic" + ) + @issuer = URI(issuer) + @client_id = client_id + @client_secret = client_secret + @token_endpoint = URI(token_endpoint) if token_endpoint + @response_type = response_type + @scope = case scope + when String + scope.split + when Array + scope + end + @access_token = access_token + @refresh_token = refresh_token + @token_endpoint_auth_method = String(token_endpoint_auth_method) + @grant_type = grant_type || (@refresh_token ? "refresh_token" : "client_credentials") + + unless SUPPORTED_AUTH_METHODS.include?(@token_endpoint_auth_method) + raise Error, "#{@token_endpoint_auth_method} is not a supported auth method" + end + + return if SUPPORTED_GRANT_TYPES.include?(@grant_type) + + raise Error, "#{@grant_type} is not a supported grant type" + end + + def token_endpoint + @token_endpoint || "#{@issuer}/token" + end + + def load(http) + return unless @token_endpoint && @token_endpoint_auth_method && @grant_type && @scope + + metadata = http.get("#{issuer}/.well-known/oauth-authorization-server").raise_for_status.json + + @token_endpoint = metadata["token_endpoint"] + @scope = metadata["scopes_supported"] + @grant_type = Array(metadata["grant_types_supported"]).find { |gr| SUPPORTED_GRANT_TYPES.include?(gr) } + @token_endpoint_auth_method = Array(metadata["token_endpoint_auth_methods_supported"]).find do |am| + SUPPORTED_AUTH_METHODS.include?(am) + end + end + + def merge(other) + obj = dup + + case other + when OAuthSession + other.instance_variables.each do |ivar| + val = other.instance_variable_get(ivar) + next unless val + + obj.instance_variable_set(ivar, val) + end + when Hash + other.each do |k, v| + obj.instance_variable_set(:"@#{k}", v) if obj.instance_variable_defined?(:"@#{k}") + end + end + obj + end + end + + module OptionsMethods + def option_oauth_session(value) + case value + when Hash + OAuthSession.new(**value) + when OAuthSession + value + else + raise TypeError, ":oauth_session must be a #{OAuthSession}" + end + end + end + + module InstanceMethods + def oauth_authentication(**args) + with(oauth_session: OAuthSession.new(**args)) + end + + def with_access_token + oauth_session = @options.oauth_session + + oauth_session.load(self) + + grant_type = oauth_session.grant_type + + headers = {} + form_post = { "grant_type" => grant_type, "scope" => Array(oauth_session.scope).join(" ") }.compact + + # auth + case oauth_session.token_endpoint_auth_method + when "client_secret_basic" + headers["authorization"] = Authentication::Basic.new(oauth_session.client_id, oauth_session.client_secret).authenticate + when "client_secret_post" + form_post["client_id"] = oauth_session.client_id + form_post["client_secret"] = oauth_session.client_secret + end + + case grant_type + when "client_credentials" + # do nothing + when "refresh_token" + form_post["refresh_token"] = oauth_session.refresh_token + end + + token_request = build_request("POST", oauth_session.token_endpoint, headers: headers, form: form_post) + token_request.headers.delete("authorization") unless oauth_session.token_endpoint_auth_method == "client_secret_basic" + + token_response = request(token_request) + token_response.raise_for_status + + payload = token_response.json + + access_token = payload["access_token"] + refresh_token = payload["refresh_token"] + + with(oauth_session: oauth_session.merge(access_token: access_token, refresh_token: refresh_token)) + end + + def build_request(*, _) + request = super + + return request if request.headers.key?("authorization") + + oauth_session = @options.oauth_session + + return request unless oauth_session && oauth_session.access_token + + request.headers["authorization"] = "Bearer #{oauth_session.access_token}" + + request + end + end + end + register_plugin :oauth, OAuth + end +end diff --git a/sig/chainable.rbs b/sig/chainable.rbs index 4525e49c..d4dffcae 100644 --- a/sig/chainable.rbs +++ b/sig/chainable.rbs @@ -34,6 +34,7 @@ module HTTPX | (:grpc, ?options) -> Plugins::grpcSession | (:response_cache, ?options) -> Plugins::sessionResponseCache | (:circuit_breaker, ?options) -> Plugins::sessionCircuitBreaker + | (:oauth, ?options) -> Plugins::sessionOAuth | (Symbol | Module, ?options) { (Class) -> void } -> Session | (Symbol | Module, ?options) -> Session diff --git a/sig/plugins/oauth.rbs b/sig/plugins/oauth.rbs new file mode 100644 index 00000000..1ed047c9 --- /dev/null +++ b/sig/plugins/oauth.rbs @@ -0,0 +1,54 @@ +module HTTPX + module Plugins + # + # https://gitlab.com/os85/httpx/wikis/OAuth + # + module OAuth + def self.load_dependencies: (singleton(Session) klass) -> void + + type grant_type = "client_credentials" | "refresh_token" + + type token_auth_method = "client_secret_basic" | "client_secret_post" + + SUPPORTED_GRANT_TYPES: ::Array[grant_type] + + SUPPORTED_AUTH_METHODS: ::Array[token_auth_method] + + class OAuthSession + attr_reader token_endpoint_auth_method: token_auth_method + + attr_reader grant_type: grant_type + + attr_reader client_id: String + + attr_reader client_secret: String + + attr_reader access_token: String? + + attr_reader refresh_token: String? + + attr_reader scope: Array[String]? + + def initialize: (issuer: uri, client_id: String, client_secret: String, ?access_token: String?, ?refresh_token: String?, ?scope: (Array[String] | String)?, ?token_endpoint: String?, ?response_type: String?, ?grant_type: String?, ?token_endpoint_auth_method: ::String) -> void + + def token_endpoint: () -> String + + def load: (Session http) -> void + + def merge: (instance | Hash[untyped, untyped] other) -> instance + end + + interface _AwsSdkOptions + def oauth_session: () -> OAuthSession? + end + + module InstanceMethods + def oauth_authentication: (**untyped args) -> instance + + def with_access_token: () -> instance + end + end + + type sessionOAuth = Session & OAuth::InstanceMethods + end +end diff --git a/test/support/requests/plugins/oauth.rb b/test/support/requests/plugins/oauth.rb index c968d5ed..bb013ee9 100644 --- a/test/support/requests/plugins/oauth.rb +++ b/test/support/requests/plugins/oauth.rb @@ -14,7 +14,7 @@ module Requests assert opts.oauth_session.grant_type == "client_credentials" assert opts.oauth_session.token_endpoint.to_s == "#{server.origin}/token" assert opts.oauth_session.token_endpoint_auth_method == "client_secret_basic" - assert opts.oauth_session.scope == "all" + assert opts.oauth_session.scope == %w[all] opts = HTTPX.plugin(:oauth).oauth_authentication( issuer: "https://smthelse",