Skip to content
Closed
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
58 changes: 58 additions & 0 deletions app/services/concerns/tagged_logger.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module TaggedLogger
extend ActiveSupport::Concern

included do
def logger
@logger ||= Rails.logger.tagged("class=#{self.class}").tagged(*logger_instance_tags)
end

def logger_instance_tags
@logger_instance_tags ||= []
end

def logger_add_instance_tag(tag)
if tag.is_a?(Hash)
tag.each { |k, v| logger_instance_tags << "#{k}=#{v}" }
else
logger_instance_tags << tag
end

@logger = nil # invalidating memoization
end

def log_debug(...) = logger.debug(...)
def log_info(...) = logger.info(...)
def log_warn(...) = logger.warn(...)
def log_error(...) = logger.error(...)
end
end
109 changes: 109 additions & 0 deletions app/services/oauth_clients/token_fetcher.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module OAuthClients
##
# Service class to obtain an access token for the given User and OAuthClient.
# Requires the user to already have connected through the given application through
# the ConnectionManager, i.e. an OAuthClientToken must already exist.
# Takes care of refreshing expired tokens.
class TokenFetcher
include Dry::Monads::Result(SimpleError)
include TaggedLogger

attr_reader :user

def initialize(user:)
@user = user
logger_add_instance_tag(user_id: @user&.id)
end

##
# Obtains an access token for the given OAuthClient, refreshing it beforehand if necessary.
def access_token_for(oauth_client:)
log_debug("Obtaining token at client #{oauth_client&.id}.")
token = OAuthClientToken.find_by(user:, oauth_client:)
if token.nil?
log_warn("Could not find an existing token.")
return Failure(SimpleError.new(source: self.class, code: :missing_token))
end

if expired?(token)
refresh(token)
else
Success(token.access_token)
end
end

private

def refresh(token)
log_info("Refreshing expired access token.")
refresh_token_request(token).bind do |json|
access_token, refresh_token, expires_in = json.values_at("access_token", "refresh_token", "expires_in")
if access_token.blank?
log_error("Received invalid JSON response from token endpoint. Expected at least 'access_token', got #{json.keys}.")
return Failure(SimpleError.new(source: self.class, code: :token_refresh_response_invalid))
end

begin
token.update!(access_token:, refresh_token:, expires_in:)
rescue ActiveRecord::StaleObjectError
log_info("Access token was already refreshed in background. Reloading from database.")
token.reload
end

Success(token.access_token)
end
end

def expired?(token)
return false if token.expires_in.nil?

token.updated_at + token.expires_in.seconds < margin(token).from_now
end

# Rotating access tokens before they actually do expire, to give code using the access token returned by this service
# for some time to make use of it.
def margin(token)
[token.expires_in.seconds / 10, 60.seconds].min
end

def refresh_token_request(token)
client = token.oauth_client

TokenRequest.new(
token_endpoint: client.integration.oauth_configuration.token_endpoint,
client_id: client.client_id,
client_secret: client.client_secret
).refresh(token.refresh_token)
end
end
end
93 changes: 93 additions & 0 deletions app/services/oauth_clients/token_request.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module OAuthClients
class TokenRequest
include Dry::Monads::Result(SimpleError)

class << self
def for_provider(provider)
new(
token_endpoint: provider.token_endpoint,
client_id: provider.client_id,
client_secret: provider.client_secret
)
end
end

def initialize(token_endpoint:, client_id:, client_secret:)
@token_endpoint = token_endpoint
@client_id = client_id
@client_secret = client_secret
end

def refresh(refresh_token)
request_token(form: { grant_type: OpenProject::OAuth2::REFRESH_TOKEN_GRANT_TYPE, refresh_token: })
end

def exchange(access_token, audience, scope)
parameters = {
grant_type: OpenProject::OAuth2::TOKEN_EXCHANGE_GRANT_TYPE,
subject_token: access_token,
subject_token_type: OpenProject::OAuth2::ACCESS_TOKEN_TYPE,
audience:
}
parameters[:scope] = scope unless scope.nil?

request_token(form: parameters)
end

private

def request_token(form:)
response = authenticated_request.post(@token_endpoint, form:)
error = SimpleError.new(payload: response, source: self.class, code: :error)

case response
in status: 200
Success(response.json)
in status: 401
Failure(error.with(code: :unauthorized))
in status: 403
Failure(error.with(code: :forbidden))
else
Failure(error)
end
end

def authenticated_request
# According to https://www.rfc-editor.org/rfc/rfc6749.html#section-2.3.1
# Client ID and Client Secret must be form-encoded. Otherwise characters such as colon (:)
# would not be allowed in the Client ID, since HTTP Basic Auth does not support it
# as per https://datatracker.ietf.org/doc/html/rfc7617#section-2
OpenProject.httpx.plugin(:basic_auth).basic_auth(CGI.escape(@client_id), CGI.escape(@client_secret))
end
end
end
2 changes: 2 additions & 0 deletions config/initializers/zeitwerk.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@
"OpenIDConnect"
when "oauth"
"OAuth"
when "oauth2"
"OAuth2"
when /\Aclamav_(.*)\z/
"ClamAV#{default_inflect($1, abspath)}"
when /\A(.*)_sso\z/
Expand Down
1 change: 1 addition & 0 deletions lib/open_project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
require "open_project/logging"
require "open_project/patches"
require "open_project/mime_type"
require "open_project/oauth2"
require "open_project/custom_styles/design"
require "open_project/httpx_appsignal"
require "open_project/httpx_ssrf_custom_error_message"
Expand Down
38 changes: 38 additions & 0 deletions lib/open_project/oauth2.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module OpenProject
module OAuth2
ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"

REFRESH_TOKEN_GRANT_TYPE = "refresh_token"
TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def configured?
def token_exchange_capable?
return false if grant_types_supported.blank?

grant_types_supported.include?(OpenProject::OpenIDConnect::TOKEN_EXCHANGE_GRANT_TYPE)
grant_types_supported.include?(OpenProject::OAuth2::TOKEN_EXCHANGE_GRANT_TYPE)
end

def icon
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ def fetch_idp_token
end

def exchange_token_request(idp_token, audience)
TokenRequest.new(provider:).exchange(idp_token, audience, @scope).alt_map do
it.with(code: :"token_exchange_#{it.code}", source: self.class)
OAuthClients::TokenRequest.for_provider(provider).exchange(idp_token, audience, @scope).alt_map do |error|
error.with(code: :"token_exchange_#{error.code}", source: self.class)
end
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ def exchange_instead_of_refresh(token)
end

def refresh_token_request(refresh_token)
TokenRequest.new(provider:).refresh(refresh_token).alt_map do
it.with(code: :"token_refresh_#{it.code}", source: self.class)
OAuthClients::TokenRequest.for_provider(provider).refresh(refresh_token).alt_map do |error|
error.with(code: :"token_refresh_#{error.code}", source: self.class)
end
end

Expand Down
Loading
Loading