Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Oauth2 #457

Draft
wants to merge 17 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ jobs:
fail-fast: false
matrix:
ruby:
- '3.4'
- '3.3'
- '3.2'
- '3.1'
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion jira-ruby.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions lib/jira-ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
13 changes: 13 additions & 0 deletions lib/jira/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -82,6 +91,8 @@ class Client
auth_type
proxy_address
proxy_port
proxy_port
proxy_uri
proxy_username
proxy_password
use_cookies
Expand Down Expand Up @@ -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
Expand Down
272 changes: 272 additions & 0 deletions lib/jira/oauth2_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@

Check failure on line 1 in lib/jira/oauth2_client.rb

View workflow job for this annotation

GitHub Actions / rubocop

Style/FrozenStringLiteralComment: Missing frozen string literal comment.
module JIRA

Check failure on line 2 in lib/jira/oauth2_client.rb

View workflow job for this annotation

GitHub Actions / rubocop

Layout/LeadingEmptyLines: Unnecessary blank line at the beginning of the source.
# 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?

Check failure on line 145 in lib/jira/oauth2_client.rb

View workflow job for this annotation

GitHub Actions / rubocop

Style/GuardClause: Use a guard clause (`return if options.slice(:access_token, :refresh_token).empty?`) instead of wrapping the code inside a conditional expression.

Check failure on line 145 in lib/jira/oauth2_client.rb

View workflow job for this annotation

GitHub Actions / rubocop

Style/IfUnlessModifier: Favor modifier `unless` usage when having a single-line body. Another good alternative is the usage of control flow `&&`/`||`.
@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

Check failure on line 197 in lib/jira/oauth2_client.rb

View workflow job for this annotation

GitHub Actions / rubocop

Style/AccessModifierDeclarations: `private` should not be inlined in method definitions.
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

Check failure on line 212 in lib/jira/oauth2_client.rb

View workflow job for this annotation

GitHub Actions / rubocop

Layout/LeadingCommentSpace: Missing space after `#`.
params = params.dup
# params[:scope] ||= scope
params[:scope] ||= 'WRITE'

if false == params[:state]

Check failure on line 217 in lib/jira/oauth2_client.rb

View workflow job for this annotation

GitHub Actions / rubocop

Style/YodaCondition: Reverse the order of the operands `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)

Check failure on line 230 in lib/jira/oauth2_client.rb

View workflow job for this annotation

GitHub Actions / rubocop

Style/HashSyntax: Use the new Ruby 1.9 hash syntax.
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)

Check failure on line 251 in lib/jira/oauth2_client.rb

View workflow job for this annotation

GitHub Actions / rubocop

Style/RedundantParentheses: Don't use parentheses around a variable.
end

def make_request(http_method, url, body = '', headers = {})
opts = {
headers: headers
}
if [:post, :put, :patch].include?(http_method)

Check failure on line 258 in lib/jira/oauth2_client.rb

View workflow job for this annotation

GitHub Actions / rubocop

Style/IfUnlessModifier: Favor modifier `if` usage when having a single-line body. Another good alternative is the usage of control flow `&&`/`||`.
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
26 changes: 22 additions & 4 deletions lib/jira/request_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions spec/jira/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading