diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index f86e909b..15017ecc 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -10,6 +10,7 @@ jobs: fail-fast: false matrix: ruby: + - '3.4' - '3.3' - '3.2' - '3.1' diff --git a/Gemfile b/Gemfile index f22f0f44..0f745006 100644 --- a/Gemfile +++ b/Gemfile @@ -13,6 +13,7 @@ group :development do end group :development, :test do + gem 'byebug' gem 'pry' # this was in the original Gemfile - but only needed in development & test gem 'rubocop' gem 'rubocop-rspec', require: false diff --git a/jira-ruby.gemspec b/jira-ruby.gemspec index a4a22508..5ac56892 100644 --- a/jira-ruby.gemspec +++ b/jira-ruby.gemspec @@ -22,8 +22,10 @@ Gem::Specification.new do |s| s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } s.require_paths = ['lib'] + # Runtime Dependencies s.add_dependency 'activesupport' s.add_dependency 'atlassian-jwt' s.add_dependency 'multipart-post' - s.add_dependency 'oauth', '~> 1.0' + s.add_dependency 'oauth', '~> 0.5', '>= 0.5.0' + s.add_dependency 'oauth2', '~> 2.0', '>= 2.0.9' end diff --git a/lib/jira-ruby.rb b/lib/jira-ruby.rb index a5ccd060..0add302f 100644 --- a/lib/jira-ruby.rb +++ b/lib/jira-ruby.rb @@ -48,6 +48,7 @@ require 'jira/request_client' require 'jira/oauth_client' +require 'jira/oauth2_client' require 'jira/http_client' require 'jira/jwt_client' require 'jira/client' diff --git a/lib/jira/client.rb b/lib/jira/client.rb index 17ebbecf..cf908a09 100644 --- a/lib/jira/client.rb +++ b/lib/jira/client.rb @@ -74,6 +74,15 @@ class Client rest_base_path consumer_key consumer_secret + client_id + client_secret + authorize_url + token_url + auth_scheme + redirect_uri + oauth2_client_options + access_token + refresh_token ssl_verify_mode ssl_version use_ssl @@ -82,6 +91,8 @@ class Client auth_type proxy_address proxy_port + proxy_port + proxy_uri proxy_username proxy_password use_cookies @@ -138,6 +149,8 @@ def initialize(options = {}) end case options[:auth_type] + when :oauth2 + @request_client = Oauth2Client.new(@options) when :oauth, :oauth_2legged @request_client = OauthClient.new(@options) @consumer = @request_client.consumer diff --git a/lib/jira/oauth2_client.rb b/lib/jira/oauth2_client.rb new file mode 100644 index 00000000..b63356a4 --- /dev/null +++ b/lib/jira/oauth2_client.rb @@ -0,0 +1,272 @@ + +module JIRA + # Client using OAuth 2.0 + # + # == OAuth 2.0 Overview + # + # OAuth 2.0 separates the roles of Resource Server and Authentication Server. + # + # The Resource Server will be Jira. + # The Authentication Server can be Jira or some other OAuth 2.0 Authentication server. + # For example you can use Git Hub Enterprise allowing all your GHE users are users which can use the Jira REST API. + # + # The impact of this is that while this gem handles calls to Jira as the Resource Server, + # communication with the Authentication Server may be out of the scope of this gem. + # Where other Request Clients authenticate and call the Jira REST API, + # calling code must authenticate and pass the credentials to this class which can then call the API. + # + # The Resource Server and the Authentication Server need to communicate with each other, + # and require clients of the Resource Server + # to communicate reflecting its communication with the Authentication Server + # as a necessity of secure authentication. + # Where this Request Client can support authentication is facilitating that consistent communication. + # It helps format the Authentication Request in a way which will be needed with the Resource Server + # and it accepts initialization from the OAuth 2.0 authentication. + # + # While a single threaded web application can keep the same Request Client object, + # a multi-process one may need to initialize the Request Client from an Access Token. + # This requires different initialization for making an Authentication Request, using an Access Token, + # and another way for refreshing the Access Token. + # + # === Authentication Request + # + # When no credentials have been established, the first step is to redirect to the Authentication Server + # to make an Authorization Request. + # That server will serve any needed web forms. + # + # When the Authentication Request is successful, it will redirect to a call back URI which was provided. + # + # === Access Request + # + # A successful Authentication Request sends an Authentication Code to the callback URI. + # This is used to make an Access Request which provides an Access Token, + # a Refresh Token, and the expiration timestamp. + # + # + # + # == Process + # + # === Register Client + # + # Register your application with the Authentication Server. + # This will provide a Client ID and a Client SECRET used by OAuth 2.0. + # + # === Authentication Request + # + # Get the URI to redirect for the Authentication Request from this RequestClient. + # + # === Implement Callback + # + # Implement the callback URI in your app for the result of the Authentication Request. + # + # Verify the CSRF Prevention State. + # This is a value sent to the Authentication Server which is sent to the callback. + # This is a value that a forger would not be able to provide. + # + # To be secure, this should not be compared with some other part of the HTTP request to the callback, + # such as in a cookie or session. + # + # === Access Request. + # + # The callback next makes a call to this RequestClient to use the Authentication Code to get the Access Token. + # This Access Token is used to make Jira REST API calls using OAuth 2.0. + # + # @example Make Authentication Request + # code code code + # code code code + # + # @example Authentication Result and Access Request + # code code code + # code code code + # + # @example Refresh Token + # code code code + # code code code + # + # @example Call Jira API + # code code code + # code code code + # + # @since 3.1.0 + # + # @!attribute [r] client_id + # @return [String] The CLIENT ID registered with the Authentication Server + # @!attribute [r] client_secret + # @return [String] The CLIENT SECRET registered with the Authentication Server + # @!attribute [r] csrf_state + # @return [String] An unpredictable value which a CSRF forger would not be able to provide + # @!attribute [r] oauth2_client + # @return [OAuth2::Client] The oauth2 gem client object used. + # @!attribute [r] oauth2_client_options + # @return [Hash] The oauth2 gem options for the client object. + # @!attribute [r] prior_grant_type + # @return [String] The grant type used to create the current Access Token. + # @!attribute [r] access_token + # @return [OAuth2::AccessToken] An object for the Access Token. + # + class Oauth2Client < RequestClient + attr_reader :prior_grant_type, :access_token, :oauth2_client_options, :client_id, :client_secret, :csrf_state + + # @private + OAUTH2_CLIENT_OPTIONS_KEYS = + %i[auth_scheme authorize_url redirect_uri token_url max_redirects site + use_ssl ssl_verify_mode ssl_version].freeze + + # @private + DEFAULT_OAUTH2_CLIENT_OPTIONS = { + use_ssl: true, + auth_scheme: 'request_body', + authorize_url: '/rest/oauth2/latest/authorize', + token_url: '/rest/oauth2/latest/token' + }.freeze + + # @param [Hash] options Options as passed from JIRA::Client constructor. + # @option options [String] :site The URL of the Jira in the role as Resource Server + # @option options [String] :auth_site The URL of the Authentication Server + # @option options [String] :client_id The OAuth 2.0 client id as registered with the Authentication Server + # @option options [String] :client_secret The OAuth 2.0 client secret as registered with the Authentication Server + # @option options [String] :auth_scheme Way of passing parameters for authentication (defaults to 'request_body') + # @option options [String] :authorize_url The Authorization Request URI (defaults to '/rest/oauth2/latest/authorize') + # @option options [String] :token_url The Jira Resource Server Access Request URI (defaults to '/rest/oauth2/latest/token') + # @option options [String] :redirect_uri Callback for result of Authentication Request + # @option options [Integer] :max_redirects Number of redirects allowed + # @option options [Hash] :default_headers Additional headers for requests + # @option options [Boolean] :use_ssl true if using HTTPS, false for HTTP + # @option options [Integer] :ssl_verify_mode OpenSSL::SSL::VERIFY_PEER or OpenSSL::SSL::VERIFY_NONE + # @option options [String] :cert_path Full path to certificate verifying server identity. + # @option options [String] :ssl_client_cert Path to client public key certificate. + # @option options [String] :ssl_client_key Path to client private key. + # @option options [Symbol] :ssl_version Version of TLS or SSL, (e.g. :TLSv1_2) + # @option options [String] :proxy_uri Proxy URI + # @option options [String] :proxy_user Proxy user + # @option options [String] :proxy_password Proxy Password + def initialize(options) + init_oauth2_options(options) + unless options.slice(:access_token, :refresh_token).empty? + @access_token = access_token_from_options(options) + end + end + + # @private + private def init_oauth2_options(options) + @client_id = options[:client_id] + @client_secret = options[:client_secret] + + @oauth2_client_options = DEFAULT_OAUTH2_CLIENT_OPTIONS.merge(options).slice(*OAUTH2_CLIENT_OPTIONS_KEYS) + + @oauth2_client_options[:connection_opts] ||= {} + @oauth2_client_options[:connection_opts][:headers] ||= options[:default_headers] if options[:default_headers] + + if options[:use_ssl] + @oauth2_client_options[:connection_opts][:ssl] ||= {} + @oauth2_client_options[:connection_opts][:ssl][:version] = options[:ssl_version] if options[:ssl_version] + @oauth2_client_options[:connection_opts][:ssl][:verify] = options[:ssl_verify_mode] if options[:ssl_verify_mode] + @oauth2_client_options[:connection_opts][:ssl][:ca_path] = options[:cert_path] if options[:cert_path] + @oauth2_client_options[:connection_opts][:ssl][:client_cert] = options[:ssl_client_cert] if options[:ssl_client_cert] + @oauth2_client_options[:connection_opts][:ssl][:client_key] = options[:ssl_client_key] if options[:ssl_client_key] + end + + proxy_uri = options[:proxy_uri] + proxy_user = options[:proxy_user] + proxy_password = options[:proxy_password] + if proxy_uri + @oauth2_client_options[:connection_opts][:proxy] ||= {} + proxy_opts = @oauth2_client_options[:connection_opts][:proxy] + proxy_opts[:uri] = proxy_uri + proxy_opts[:user] = proxy_user if proxy_user + proxy_opts[:password] = proxy_password if proxy_password + end + + @oauth2_client_options + end + + def oauth2_client + @oauth2_client ||= + OAuth2::Client.new(client_id, + client_secret, + oauth2_client_options) + end + + def access_token_from_options(options_local) + @prior_grant_type = 'access_token' + hash = { token: options_local[:access_token], refresh_token: options_local[:refresh_token] } + OAuth2::AccessToken.from_hash(oauth2_client, hash) + end + + # @private + private def generate_encoded_state + ran = OpenSSL::Random.random_bytes(32) + Base64.encode64(ran).strip.gsub('+', '-').gsub('/', '_') + end + + # Provides redirect URI for Authentication Request. + # + # Making an Authenticaiton Request requires redirecting to a URI on the Authentication Server. + # + # @param [String] scope The scope (default 'WRITE') + # @param [String] state Provided state or false to use no state (default random 32 bytes) + # @param [Hash] params Additional parameters to pass to the oauth2 gem. + # @option params [String,NilClass] :redirect_uri Callback for result of Authentication Request + # @return [String] URI to redirect to for Authentication Request + def authorize_url(params = {}) + #TODO: Change to one hash argument + params = params.dup + # params[:scope] ||= scope + params[:scope] ||= 'WRITE' + + if false == params[:state] + params.delete(:state) + else + # @csrf_state = state || generate_encoded_state + @csrf_state = params[:state] || generate_encoded_state + params[:state] = @csrf_state + end + + oauth2_client.auth_code.authorize_url(params) + end + + def get_token(code, opts = {}) + @prior_grant_type = 'authorization_code' + @access_token = oauth2_client.auth_code.get_token(code, { :redirect_uri => oauth2_client.options[:authorize_url] }, opts) + end + + def token + access_token&.token + end + + def refresh_token + access_token&.refresh_token + end + + def expires_at + access_token&.expires_at + end + + def refresh + @prior_grant_type = 'refresh_token' + @access_token = @access_token.refresh(grant_type: 'refresh_token', refresh_token: refresh_token) + end + + def authenticated? + !!(@authenticated) + end + + def make_request(http_method, url, body = '', headers = {}) + opts = { + headers: headers + } + if [:post, :put, :patch].include?(http_method) + opts[:body] = body + end + + response = access_token.request(http_method, url, opts) + + @authenticated = true + response + end + + def make_multipart_request(url, data, headers = {}) + byebug + end + end +end diff --git a/lib/jira/request_client.rb b/lib/jira/request_client.rb index 5a7d37d4..27bc2362 100644 --- a/lib/jira/request_client.rb +++ b/lib/jira/request_client.rb @@ -12,16 +12,34 @@ class RequestClient def request(*args) response = make_request(*args) - raise HTTPError, response unless response.is_a?(Net::HTTPSuccess) - response + if response.is_a?(Net::HTTPResponse) + raise HTTPError, response unless response.is_a?(Net::HTTPSuccess) + return response + end + + if response.respond_to?(:status) + raise HTTPError, response unless (200..299).include?(response&.status) + return response + end + + raise HTTPError, response end def request_multipart(*args) response = make_multipart_request(*args) - raise HTTPError, response unless response.is_a?(Net::HTTPSuccess) - response + if response.is_a?(Net::HTTPResponse) + raise HTTPError, response unless response.is_a?(Net::HTTPSuccess) + return response + end + + if response.respond_to?(:status) + raise HTTPError, response unless (200..299).include?(response&.status) + return response + end + + raise HTTPError, response end def make_request(*args) diff --git a/spec/jira/client_spec.rb b/spec/jira/client_spec.rb index 0014862b..45779cc0 100644 --- a/spec/jira/client_spec.rb +++ b/spec/jira/client_spec.rb @@ -132,6 +132,7 @@ let(:request) { subject.request_client.class } let(:successful_response) do response = double('response') + allow(response).to receive(:is_a?).with(Net::HTTPResponse).and_return(true) allow(response).to receive(:is_a?).with(Net::HTTPSuccess).and_return(true) response end diff --git a/spec/jira/oauth2_client_spec.rb b/spec/jira/oauth2_client_spec.rb new file mode 100644 index 00000000..4de2c83d --- /dev/null +++ b/spec/jira/oauth2_client_spec.rb @@ -0,0 +1,461 @@ +require 'spec_helper' +require 'oauth2' + +# Translates query string from a URI into a hash. +def query_params_to_h(uri) + uri_object = URI.parse(uri) + uri_object.query&.split('&')&.each_with_object({}) do |param, hash | + if /(?[\w%]+)=(?[-_%\w]+)/ =~ param + hash[key] = value + end + end +end + +describe JIRA::Oauth2Client do + let(:site) { 'https://jira_server' } + let(:auth_site) { 'https://auth_server' } + let(:client_id) { 'Client ID String Value' } + let(:client_secret) { 'Client Secret String Value' } + let(:auth_scheme) { 'headers' } + let(:authorize_url) { '/custom/custom_authorize_url' } + let(:token_url) { 'custom/custom_toke_url' } + let(:redirect_uri) { 'http:/localhost/oauth2_auth_result' } + let(:max_redirects) { 16 } + let(:headers) { { 'X-SECURITY' => 'D1B844A0F8BD99CA616760F1FD23CB2AE04EAFE0035203F6E73D03E6CCD24777' } } + let(:ssl_flag) { true } + let(:ssl_version) { :TLSv1_2 } + let(:ssl_verify) { 0 } + let(:ca_path) { '/etc/ssl/certs/jira_ca,pem' } + let(:client_cert) { 'jira_client.pem' } + let(:client_key) { 'jira_client.key' } + let(:minimum_options) do + { + site: site, + client_id: client_id, + client_secret: client_secret, + redirect_uri: redirect_uri, + } + end + let(:full_options) do + minimum_options.merge( + { + use_ssl: ssl_flag, + auth_scheme: auth_scheme, + authorize_site: auth_site, + authorize_url: authorize_url, + token_url: token_url, + max_redirects: max_redirects, + default_headers: headers, + # oauth2_client_options: { site: auth_site } + ssl_version: ssl_version, + ssl_verify_mode: ssl_verify, + cert_path: ca_path, + ssl_client_cert: client_cert, + ssl_client_key: client_key, + } + ) + end + let(:proxy_site) { 'https://proxy_server' } + let(:proxy_user) { 'ironman' } + let(:proxy_password) { 'iamironman' } + let(:proxy_options) do + full_options.merge( + { + proxy_uri: proxy_site, + proxy_user: proxy_user, + proxy_password: proxy_password + } + ) + end + + context 'options from before any OAuth 2 client' do + describe '.new' do + context 'setting options' do + subject(:request_client) do + described_class.new(full_options) + end + + it 'sets oauth2 client options' do + expect(request_client.class).to eq(JIRA::Oauth2Client) + expect(request_client.oauth2_client.class).to eq(OAuth2::Client) + expect(request_client.client_id).to eq(client_id) + expect(request_client.client_secret).to eq(client_secret) + expect(request_client.oauth2_client_options[:site]).to eq(site) + expect(request_client.oauth2_client_options[:use_ssl]).to eq(ssl_flag) + expect(request_client.oauth2_client_options[:auth_scheme]).to eq(auth_scheme) + expect(request_client.oauth2_client_options[:authorize_url]).to eq(authorize_url) + expect(request_client.oauth2_client_options[:redirect_uri]).to eq(redirect_uri) + expect(request_client.oauth2_client_options[:token_url]).to eq(token_url) + expect(request_client.oauth2_client_options[:max_redirects]).to eq(max_redirects) + expect(request_client.oauth2_client_options.dig(:connection_opts, :ssl, :version)).to eq(ssl_version) + expect(request_client.oauth2_client_options.dig(:connection_opts, :ssl, :verify)).to eq(ssl_verify) + expect(request_client.oauth2_client_options.dig(:connection_opts, :ssl, :ca_path)).to eq(ca_path) + expect(request_client.oauth2_client_options.dig(:connection_opts, :ssl, :client_cert)).to eq(client_cert) + expect(request_client.oauth2_client_options.dig(:connection_opts, :ssl, :client_key)).to eq(client_key) + expect(request_client.oauth2_client_options.dig(:connection_opts, :headers)).to eq(headers) + expect(request_client.oauth2_client.site).to eq(site) + expect(request_client.oauth2_client.id).to eq(client_id) + expect(request_client.oauth2_client.secret).to eq(client_secret) + end + end + + context 'using default options' do + subject(:request_client) do + described_class.new(minimum_options) + end + + it 'uses default oauth2 client options' do + expect(request_client.class).to eq(JIRA::Oauth2Client) + expect(request_client.oauth2_client.class).to eq(OAuth2::Client) + expect(request_client.client_id).to eq(client_id) + expect(request_client.client_secret).to eq(client_secret) + expect(request_client.oauth2_client_options[:site]).to eq(site) + expect(request_client.oauth2_client_options[:use_ssl]).to eq(JIRA::Oauth2Client::DEFAULT_OAUTH2_CLIENT_OPTIONS[:use_ssl]) + expect(request_client.oauth2_client_options[:auth_scheme]).to eq(JIRA::Oauth2Client::DEFAULT_OAUTH2_CLIENT_OPTIONS[:auth_scheme]) + expect(request_client.oauth2_client_options[:authorize_url]).to eq(JIRA::Oauth2Client::DEFAULT_OAUTH2_CLIENT_OPTIONS[:authorize_url]) + expect(request_client.oauth2_client_options[:token_url]).to eq(JIRA::Oauth2Client::DEFAULT_OAUTH2_CLIENT_OPTIONS[:token_url]) + expect(request_client.oauth2_client_options[:redirect_uri]).to eq(redirect_uri) + expect(request_client.oauth2_client_options.dig(:connection_opts, :ssl, :version)).to be_nil + expect(request_client.oauth2_client_options.dig(:connection_opts, :ssl, :verify)).to be_nil + expect(request_client.oauth2_client_options.dig(:connection_opts, :ssl, :ca_path)).to be_nil + expect(request_client.oauth2_client_options.dig(:connection_opts, :ssl, :client_cert)).to be_nil + expect(request_client.oauth2_client_options.dig(:connection_opts, :ssl, :client_key)).to be_nil + expect(request_client.oauth2_client_options.dig(:connection_opts, :headers)).to be_nil + expect(request_client.oauth2_client.site).to eq(site) + expect(request_client.oauth2_client.id).to eq(client_id) + expect(request_client.oauth2_client.secret).to eq(client_secret) + end + end + + context 'using a proxy' do + subject(:request_client) do + described_class.new(proxy_options) + end + + it 'creates a proxy configured Oauth2::Client on initialize' do + expect(request_client.class).to eq(JIRA::Oauth2Client) + expect(request_client.oauth2_client.class).to eq(OAuth2::Client) + expect(request_client.client_id).to eq(client_id) + expect(request_client.client_secret).to eq(client_secret) + expect(request_client.oauth2_client_options[:site]).to eq(site) + expect(request_client.oauth2_client_options[:use_ssl]).to eq(ssl_flag) + expect(request_client.oauth2_client_options[:auth_scheme]).to eq(auth_scheme) + expect(request_client.oauth2_client_options[:authorize_url]).to eq(authorize_url) + expect(request_client.oauth2_client_options[:redirect_uri]).to eq(redirect_uri) + expect(request_client.oauth2_client_options[:token_url]).to eq(token_url) + expect(request_client.oauth2_client_options[:max_redirects]).to eq(max_redirects) + expect(request_client.oauth2_client_options.dig(:connection_opts, :ssl, :version)).to eq(ssl_version) + expect(request_client.oauth2_client_options.dig(:connection_opts, :ssl, :verify)).to eq(ssl_verify) + expect(request_client.oauth2_client_options.dig(:connection_opts, :ssl, :ca_path)).to eq(ca_path) + expect(request_client.oauth2_client_options.dig(:connection_opts, :ssl, :client_cert)).to eq(client_cert) + expect(request_client.oauth2_client_options.dig(:connection_opts, :ssl, :client_key)).to eq(client_key) + expect(request_client.oauth2_client_options.dig(:connection_opts, :headers)).to eq(headers) + expect(request_client.oauth2_client.site).to eq(site) + expect(request_client.oauth2_client.id).to eq(client_id) + expect(request_client.oauth2_client.secret).to eq(client_secret) + expect(request_client.oauth2_client.options.dig(:connection_opts, :proxy, :uri)).to eq(proxy_site) + expect(request_client.oauth2_client.options.dig(:connection_opts, :proxy, :user)).to eq(proxy_user) + expect(request_client.oauth2_client.options.dig(:connection_opts, :proxy, :password)).to eq(proxy_password) + end + end + + context 'passing options to oauth2 client' do + subject(:request_client) do + described_class.new(full_options) + end + let(:oauth2_client) { instance_double(OAuth2::Client) } + + it 'passes oauth2 client options to creating oauth2 client' do + expect(OAuth2::Client).to receive(:new).with(client_id, client_secret, request_client.oauth2_client_options).and_return(oauth2_client) + + oauth2_client_result = request_client.oauth2_client + + expect(oauth2_client_result).to eq(oauth2_client) + end + end + end + end + + context 'prior to Authentication Request' do + subject(:request_client) do + described_class.new(site: auth_site, + client_id: client_id, + client_secret: client_secret) + end + let(:redirect_uri) { 'http://localhost/auth_response' } + + describe '.authorize_url' do + let(:state_given) { 'abc-123_unme' } + + context 'default generated CSRF state' do + it 'provides authorization redirect URI' do + + authorize_url = request_client.authorize_url( redirect_uri: redirect_uri ) + + expect(authorize_url).not_to be_nil + uri = URI.parse(authorize_url) + expect(uri.hostname).to eq(URI.parse(auth_site).hostname) + expect(uri.path).to eq(request_client.oauth2_client_options[:authorize_url]) + query_params = query_params_to_h(authorize_url) + expect(query_params['response_type']).to eq('code') + expect(query_params['scope']).to eq('WRITE') + expect(query_params['redirect_uri']).to eq( CGI.escape(redirect_uri)) + expect(query_params['state']).not_to be_nil + end + end + + context 'without using CSRF state' do + it 'disables CSRF STATE' do + + authorize_url = request_client.authorize_url(state: false, redirect_uri: redirect_uri ) + + expect(authorize_url).not_to be_nil + uri = URI.parse(authorize_url) + expect(uri.hostname).to eq(URI.parse(auth_site).hostname) + expect(uri.path).to eq(request_client.oauth2_client_options[:authorize_url]) + query_params = query_params_to_h(authorize_url) + expect(query_params['response_type']).to eq('code') + expect(query_params['scope']).to eq('WRITE') + expect(query_params['redirect_uri']).to eq( CGI.escape(redirect_uri)) + expect(query_params['state']).to be_nil + end + end + + context 'using given CSRF state' do + it 'uses given CSRF STATE' do + + authorize_url = request_client.authorize_url(state: state_given, redirect_uri: redirect_uri ) + + expect(authorize_url).not_to be_nil + uri = URI.parse(authorize_url) + expect(uri.hostname).to eq(URI.parse(auth_site).hostname) + expect(uri.path).to eq(request_client.oauth2_client_options[:authorize_url]) + query_params = query_params_to_h(authorize_url) + expect(query_params['response_type']).to eq('code') + expect(query_params['scope']).to eq('WRITE') + expect(query_params['redirect_uri']).to eq( CGI.escape(redirect_uri)) + expect(query_params['state']).to eq(state_given) + end + end + + context 'using a proxy' do + let(:proxy_site) { 'https://proxy_server' } + let(:proxy_user) { 'brassman' } + let(:proxy_password) { 'iambrassman' } + subject(:proxy_request_client) do + params = { site: auth_site, redirect_uri: redirect_uri, proxy_uri: proxy_site, proxy_user: proxy_user, proxy_password: proxy_password } + described_class.new(client_id: client_id, + client_secret: client_secret, + site: auth_site, + oauth2_client_options: params) + end + + it 'provides authorization redirect URI' do + + params = { redirect_uri: redirect_uri, proxy_uri: proxy_site, proxy_user: proxy_user, proxy_password: proxy_password } + authorize_url = proxy_request_client.authorize_url( params ) + + expect(authorize_url).not_to be_nil + uri = URI.parse(authorize_url) + expect(uri.hostname).to eq(URI.parse(auth_site).hostname) + expect(uri.path).to eq(request_client.oauth2_client_options[:authorize_url]) + query_params = query_params_to_h(authorize_url) + expect(query_params['response_type']).to eq('code') + expect(query_params['scope']).to eq('WRITE') + expect(query_params['redirect_uri']).to eq( CGI.escape(redirect_uri)) + expect(query_params['state']).not_to be_nil + expect(query_params['proxy_uri']).to eq( CGI.escape(proxy_site) ) + expect(query_params['proxy_user']).to eq(proxy_user) + expect(query_params['proxy_password']).to eq(proxy_password) + end + end + end + end + + context 'with Authentication Code' do + subject(:request_client) do + described_class.new(site: site, + client_id: client_id, + client_secret: client_secret) + end + let(:code) { 'Authentication Code String Value' } + let(:token) { 'Access Token String Value' } + let(:refresh_token) { 'Refresh Token String Value' } + let(:access_token) do + OAuth2::AccessToken.new(request_client.oauth2_client, + token, + { refresh_token: refresh_token, + expires_in: 3600, + expires_at: (Time.now + 3600).to_i }) + end + + describe '.get_token' do + it 'makes Access Request to get token from given code' do + expect(request_client.oauth2_client.auth_code).to receive(:get_token).and_return(access_token) + + request_client.get_token(code) + + expect(request_client.prior_grant_type).to eq('authorization_code') + expect(request_client.token).to eq(token) + expect(request_client.refresh_token).to eq(refresh_token) + end + end + end + + context 'with Access Token' do + let(:token) { 'Access Token String Value' } + let(:refresh_token) { 'Refresh Token String Value' } + + describe '.new' do + + it 'sets oauth2 client from token' do + + request_client = described_class.new(client_id: client_id, + client_secret: client_secret, + site: auth_site, + access_token: token) + + expect(request_client.prior_grant_type).to eq('access_token') + expect(request_client.token).to eq(token) + end + end + end + + context 'with Refresh Token' do + let(:token) { 'Prior Access Token String Value' } + let(:refresh_token) { 'Prior Refresh Token String Value' } + let(:token_updated) { 'Updated Access Token String Value' } + let(:refresh_token_updated) { 'Updated Refresh Token String Value' } + subject(:request_client) do + described_class.new(client_id: client_id, + client_secret: client_secret, + site: auth_site, + refresh_token: refresh_token) + end + let(:access_token_updated) do + OAuth2::AccessToken.new(request_client.oauth2_client, + token_updated, + { refresh_token: refresh_token_updated, + expires_in: 3600, + expires_at: (Time.now + 3600).to_i }) + end + + describe '' do + it 'refreshes the token' do + expect(request_client.access_token).to receive(:refresh).with(grant_type: 'refresh_token', refresh_token: refresh_token).and_return(access_token_updated) + + request_client.refresh + + expect(request_client.prior_grant_type).to eq('refresh_token') + expect(request_client.token).to eq(token_updated) + expect(request_client.refresh_token).to eq(refresh_token_updated) + end + end + end + + context 'from client using Access Token' do + let(:oauth2_client) { instance_double(OAuth2::Client) } + let(:token) { 'Access Token String Value' } + let(:refresh_token) { 'Refresh Token String Value' } + let(:redirect_uri) { 'http://localhost/auth_response' } + let(:access_token) do + OAuth2::AccessToken.new(oauth2_client, + token, + { refresh_token: refresh_token, + expires_in: 3600, + expires_at: (Time.now + 3600).to_i }) + end + subject(:client) do + allow(OAuth2::AccessToken).to receive(:from_hash).and_return(access_token) + JIRA::Client.new(auth_type: :oauth2, + client_id: client_id, + client_secret: client_secret, + site: site, + access_token: token, + refresh_token: refresh_token) + end + let(:response) do + response = instance_double(OAuth2::Response) + allow(response).to receive(:status).and_return(200) + allow(response).to receive(:body).and_return('{}') + response + end + + describe '.request_client.access_token' do + it 'initializes the oauth2 Access Token' do + allow(OAuth2::Client).to receive(:new).and_return(oauth2_client) + expect(OAuth2::AccessToken).to receive(:from_hash).with(oauth2_client, + { + token: token, + refresh_token: refresh_token + }).and_return(access_token) + + access_token_result = client.request_client.access_token + + expect(access_token_result).not_to be_nil + expect(access_token_result.token).to eq(token) + expect(access_token_result.refresh_token).to eq(refresh_token) + end + end + + describe 'JIRA::Resource::ServerInfo.all' do + it 'makes an HTTP GET request' do + expect(access_token).to receive(:request).with(:get, '/jira/rest/api/2/serverInfo', anything).and_return( response ) + + result = JIRA::Resource::ServerInfo.all(client) + + end + end + + describe 'JIRA::Resource::Attachment#delete' do + let(:issue_id) { 37208 } + let(:issue) { JIRA::Resource::Issue.new(client) } + subject(:attachment) do + _attachment = JIRA::Resource::Attachment.new(client, issue: issue) + allow(_attachment).to receive(:issue_id).and_return(issue_id) + _attachment + end + + it 'makes an HTTP DELETE request' do + expect(access_token).to receive(:request).with(:delete, "/jira/rest/api/2/issue/#{issue_id}/attachments", anything).and_return( response ) + + result = attachment.delete + + expect(result).to eq(true) + end + end + + describe 'JIRA::Resource::Issue#save!' do + let(:issue_id) { 37208 } + subject(:issue) do + _issue = JIRA::Resource::Issue.new(client) + allow(_issue).to receive(:key_value).and_return(issue_id) + _issue + end + let(:custom_key) { :custom_99000 } + let(:custom_value_new) { 'New Custom Text String' } + + it 'makes an HTTP PUT request' do + allow(issue).to receive(:new_record?).and_return(false) + expect(access_token).to receive(:request).with(:put, + "/jira/rest/api/2/issue/#{issue_id}", + anything).and_return( response ) + + result = issue.save!(custom_key => custom_value_new) + + expect(result).to eq(true) + end + + it 'makes an HTTP POST request' do + allow(issue).to receive(:new_record?).and_return(true) + expect(access_token).to receive(:request).with(:post, + "/jira/rest/api/2/issue/#{issue_id}", + anything).and_return( response ) + + result = issue.save!(custom_key => custom_value_new) + + expect(result).to eq(true) + end + end + end +end diff --git a/spec/jira/oauth_client_spec.rb b/spec/jira/oauth_client_spec.rb index 5666b6c6..8645c7bb 100644 --- a/spec/jira/oauth_client_spec.rb +++ b/spec/jira/oauth_client_spec.rb @@ -9,6 +9,7 @@ let(:response) do response = double('response') + allow(response).to receive(:is_a?).with(Net::HTTPResponse).and_return(true) allow(response).to receive(:is_a?).with(Net::HTTPSuccess).and_return(true) response end