Compare commits

...

20 Commits

Author SHA1 Message Date
Wieland Lindenthal 7c6b963100
Adding OAuthClients::ConnectionManager 3 years ago
Wieland Lindenthal 57aa2849f0
Merge remote-tracking branch 'origin/dev' into feature/file-links-oauth-connection-manager 3 years ago
Frank Bergmann 14003960d6 WIP starting to reorganize ConnectionManager 3 years ago
Frank Bergmann a4542a7fb4 WIP trying to copy/refactor the OAuth2 prototype to product 3 years ago
Wieland Lindenthal aa643a503f
Consolidate two feature specs into one 3 years ago
Wieland Lindenthal 183d7c3f0e
Merge pull request #10683 from fraber/feature/file-links-oauth-js 3 years ago
Frank Bergmann 9126628949 Added spec to test OAuthClient GUI logic with Create/Reset 3 years ago
Wieland Lindenthal 31ce3a0c1a
Improve flow and guidance for setting up a file storage 3 years ago
Frank Bergmann c96f6d87a1 Fixed feature spec testing storage admin without OAuthClient. 3 years ago
Wieland Lindenthal 41fb1c86d4
Merge pull request #10679 from fraber/feature/file-links-oauth-js 3 years ago
Frank Bergmann 5361109db8 OAuthClient services and specs 3 years ago
Wieland Lindenthal 3bd5aec676
Merge pull request #10677 from fraber/feature/file-links-oauth-js 3 years ago
Frank Bergmann 120b3b6094 Contract specs for OAuthClient together with Wieland 3 years ago
Wieland Lindenthal 0997f8840b
Improve ABC size in Storages::Admin::OAuthClientsController create method 3 years ago
Wieland Lindenthal 67d46eb730
Add spec for OAuthClients::CreateContract 3 years ago
Wieland Lindenthal 4f380a1441
Rough round trip for adding OAuth client credentials to storage 3 years ago
Wieland Lindenthal 58e3744e6e
Explicit edit mode for OAuth client secret 3 years ago
Frank Bergmann 2733c0e064
Fixed RuboCop issues and moved JS to storage_form.js 3 years ago
Frank Bergmann cea5333a8f
Storage form with JS to enable OAuth2 client_secret on demand 3 years ago
Frank Bergmann c6d3bdd336
Added storages.oauth_client_id and _secret and improved host checking error handling 3 years ago
  1. 47
      app/contracts/oauth_clients/concerns/manage_storages_guarded.rb
  2. 49
      app/contracts/oauth_clients/create_contract.rb
  3. 34
      app/contracts/oauth_clients/delete_contract.rb
  4. 141
      app/controllers/oauth_clients_controller.rb
  5. 31
      app/models/oauth_client.rb
  6. 48
      app/models/oauth_client_token.rb
  7. 218
      app/services/oauth_clients/connection_manager.rb
  8. 39
      app/services/oauth_clients/create_service.rb
  9. 33
      app/services/oauth_clients/delete_service.rb
  10. 31
      app/services/oauth_clients/set_attributes_service.rb
  11. 40
      config/locales/en.yml
  12. 4
      config/locales/js-en.yml
  13. 6
      config/routes.rb
  14. 40
      db/migrate/20220503093844_create_oauth_client.rb
  15. 19
      db/migrate/20220518154147_create_oauth_client_tokens.rb
  16. 109
      modules/storages/app/controllers/storages/admin/oauth_clients_controller.rb
  17. 38
      modules/storages/app/controllers/storages/admin/storages_controller.rb
  18. 2
      modules/storages/app/models/storages/storage.rb
  19. 54
      modules/storages/app/views/storages/admin/storages/_form.html.erb
  20. 34
      modules/storages/app/views/storages/admin/storages/edit.html.erb
  21. 6
      modules/storages/app/views/storages/admin/storages/new.html.erb
  22. 38
      modules/storages/app/views/storages/admin/storages/new_oauth_client.html.erb
  23. 90
      modules/storages/app/views/storages/admin/storages/show.html.erb
  24. 10
      modules/storages/config/locales/en.yml
  25. 4
      modules/storages/config/routes.rb
  26. 0
      modules/storages/db/migrate/20220113144323_create_storage.rb
  27. 0
      modules/storages/db/migrate/20220113144759_create_file_links.rb
  28. 103
      modules/storages/spec/features/admin_storages_spec.rb
  29. 90
      spec/contracts/oauth_clients/create_contract_spec.rb
  30. 43
      spec/contracts/oauth_clients/delete_contract_spec.rb
  31. 35
      spec/factories/oauth_client_factory.rb
  32. 36
      spec/factories/oauth_client_token_factory.rb
  33. 111
      spec/models/oauth_client_token_spec.rb
  34. 337
      spec/services/oauth_clients/connection_manager_spec.rb
  35. 36
      spec/services/oauth_clients/create_service_spec.rb
  36. 36
      spec/services/oauth_clients/delete_service_spec.rb
  37. 114
      spec/services/oauth_clients/set_attributes_service_spec.rb

@ -0,0 +1,47 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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
module Concerns
module ManageStoragesGuarded
extend ActiveSupport::Concern
included do
validate :validate_user_allowed_to_manage
private
def validate_user_allowed_to_manage
unless user.admin? && user.active?
errors.add :base, :error_unauthorized
end
end
end
end
end
end

@ -0,0 +1,49 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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 CreateContract < ::ModelContract
include ActiveModel::Validations
include Concerns::ManageStoragesGuarded
include ::OAuthClients::Concerns::ManageStoragesGuarded
include ActiveModel::Validations
attribute :client_id, writable: true
validates :client_id, presence: true, length: { maximum: 255 }
attribute :client_secret, writable: true
validates :client_secret, presence: true, length: { maximum: 255 }
attribute :integration_type, writable: true
validates :integration_type, presence: true
attribute :integration_id, writable: true
validates :integration_id, presence: true
end
end

@ -0,0 +1,34 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
# See also: base_contract.rb for comments
module OAuthClients
class DeleteContract < ::DeleteContract
delete_permission :admin
end
end

@ -0,0 +1,141 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
# This controller handles OAauth2 redirects from a provider to
# "callback" page.
class OAuthClientsController < ApplicationController
before_action :find_oauth_client
# Provide the OAuth2 "callback" page:
# The OAuthClientsManager.get_token() method redirects
# here after successful authentication and authorization.
# This page gets a "code" parameter that cryptographically
# contains the approval.
# We get here by a URL like this:
# http://localhost:4200/oauth_client/1/oauth_callback?
# state=http%3A%2F%2Flocalhost%3A4200%2Fprojects%2Fdemo-project%2Foauth2_example&
# code=MQoOnUTJGFdAo5jBGD1SqnDH0PV6yioG7NoYM2zZZlK3g6LuKrGUmOxjIS1bIy7fHEfZy2WrgYcx
def callback
# oauth_client can be nil if OAuthClient was not found.
# This happens during admin setup if the user forgot to update the return_uri
# on Nextcloud after updating the OpenProject side with a new client_id and client_secret.
if !@oauth_client
flash[:error] = [I18n.t('oauth_client.errors.oauth_client_not_found'),
I18n.t('oauth_client.errors.oauth_client_not_found_explanation')]
redirect_to admin_settings_storages_path # Redirect to admin, because this only happens to sloppy admins
return
end
# Extract the cryptographic "code" that indicates that the user
# has successfully authenticated agains the OAuth2 provider and has
# provided authorization to access his resources.
code = params[:code]
if code.blank?
# The OAuth2 provider should have sent a code when using response_type = "code"
# So this could either an error from the OAuth2 provider (Nextcloud) or
# ConnectionManager has used the wrong response_type.
flash[:error] = [I18n.t('oauth_client.errors.oauth_code_not_present'),
I18n.t('oauth_client.errors.oauth_code_not_present_explanation')]
redirect_to admin_settings_storages_path # Redirect to admin, because this only happens to sloppy admins
return
end
# state is used by OpenProject to contain the redirection URL where to
# continue after receiving an OAuth2 token. So it should not be blank
state = params[:state]
if state.blank?
flash[:error] = [I18n.t('oauth_client.errors.oauth_state_not_present'),
I18n.t('oauth_client.errors.oauth_state_not_present_explanation')]
redirect_to admin_settings_storages_path # Redirect to admin, because this only happens to sloppy admins
return
end
# Start the OAuth2 manager that will handle all the rest
connection_manager = OAuthClients::ConnectionManager.new(user: User.current, oauth_client: @oauth_client)
# Exchange the code with a token using a HTTP call to the OAuth2 provider
service_result = connection_manager.code_to_token(code)
if service_result.success?
# Redirect the user to the page that initially wanted to access the OAuth2 resource.
# "state" is a variable that encapsulates the page's URL and status.
redirect_uri = connection_manager.callback_page_redirect_uri(params[:state])
redirect_to redirect_uri
else
# We got a list of errors from ConnectionManger
flash[:error] = ["#{t(:'oauth_client.errors.oauth_was_a_mess')}:"]
service_result.errors.each do |error|
flash[:error] << "#{t(:'oauth_client.errors.oauth_reported')}: #{error.full_message}"
end
redirect_user_or_admin(state)
end
end
def refresh
# Test page for refreshing OAuth2 token
if !@oauth_client
flash[:error] = [I18n.t('oauth_client.errors.oauth_client_not_found'),
I18n.t('oauth_client.errors.oauth_client_not_found_explanation')]
redirect_to admin_settings_storages_path # Redirect to admin, because this only happens to sloppy admins
return
end
# Start the OAuth2 manager that will handle all the rest
connection_manager = OAuthClients::ConnectionManager.new(user: User.current, oauth_client: @oauth_client)
connection_manager.refresh_token
# ToDo: This redirect is only for admins
redirect_to admin_settings_storages_path(@oauth_client.integration)
end
private
def redirect_user_or_admin(state)
if User.current.admin
# ToDo: Check that integration a storage is
redirect_to admin_settings_storages_path(@oauth_client.integration)
else
flash[:error] = [t(:'oauth_client.errors.oauth_issue_contact_admin')]
redirect_to state
end
end
# Storage ID coming from routes.rb preparsed
# Returns nil in case thae OAuthClient is not found.
# This can happen during setup if the user forgot to update
# the ID in the return_uri on the Nextcloud side.
def find_oauth_client
# During development we need to be able to identify the oauth_client by it's ID
@oauth_client = OAuthClient.find_by(id: params[:id])
# Nextcloud "automaticial" configuration just adds the OAuth2 client_id as the Id
# so the Nextcloud side doesn't need to know the OpenProject object ID.
if @oauth_client.nil?
@oauth_client = OAuthClient.find_by(client_id: params[:id])
end
end
end

@ -0,0 +1,31 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
class OAuthClient < ApplicationRecord
belongs_to :integration, polymorphic: true
end

@ -0,0 +1,48 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
# OAuthClientToken stores the OAuth2 Bearer+Refresh tokens that
# an OAuth2 server (Nextcloud or similar) provides after a user
# has granted access.
class OAuthClientToken < ApplicationRecord
# OAuthClientToken sits between User and OAuthClient
belongs_to :user
belongs_to :oauth_client
# There should be only one token per project and oauth_client.
validates :user, presence: true
validates :user, uniqueness: { scope: :oauth_client }
# There should be an oauth_client as parent
validates :oauth_client, presence: true
# ToDo: Cover with model spec
validates :access_token, length: { minimum: 1, maximum: 255 }
# ToDo: Cover with model spec
validates :refresh_token, length: { minimum: 1, maximum: 255 }
end

@ -0,0 +1,218 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
require "rack/oauth2"
require "uri/http"
module OAuthClients
class ConnectionManager
attr_reader :user, :rack_token, :oauth_client
def initialize(user:, oauth_client:)
@user = user
@oauth_client = oauth_client
end
# Main method to initiate the OAuth2 flow called by a "client" component
# that wants to access OAuth2 protected resources.
# Returns an OAuthClientToken object or a String in case a renew is required.
# @param state (OAuth2 RFC) encapsulates the state of the calling page (URL + params) to return
# @param scope (OAuth2 RFC) specifies the resources to access. Nextcloud only has one global scope.
def get_access_token(scope: [], state: nil)
# Check for an already existing token from last call
token = get_existing_token
return ServiceResult.new(success: true, result: token) if token.present?
# Return a String with a redirect URL to Nextcloud instead of a token
@redirect_url = redirect_to_oauth_authorize(scope:, state:)
ServiceResult.new(success: false, result: @redirect_url)
end
# The bearer/access token has expired or is due for renew for other reasons.
# Talk to OAuth2 provider to exchange the renew_token for a new bearer token.
def refresh_token
# There should already be an existing token,
# otherwise this method has been called too early (internal flow error).
oauth_client_token = get_existing_token
if oauth_client_token.nil?
return service_result_with_error(I18n.t('oauth_client.errors.refresh_token_called_without_existing_token'))
end
# Get the Rack::OAuth2::Client and call access_token!, then return a ServiceResult.
service_result = request_new_token(refresh_token: oauth_client_token.refresh_token)
return service_result unless service_result.success?
# Updated tokens, handle model checking errors and return a ServiceResult
update_oauth_client_token(oauth_client_token, service_result.result)
end
# Redirect to the "authorize" endpoint of the OAuth2 provider.
# @param state (OAuth2 RFC) encapsulates the state of the calling page (URL + params) to return
# @param scope (OAuth2 RFC) specifies the resources to access. Nextcloud only has one global scope.
def redirect_to_oauth_authorize(scope: [], state: nil)
client = rack_oauth_client # Configure and start the rack-oauth2 client
client.authorization_uri(scope:, state:)
end
# For the OAuth2 callback page: Calculate the redirection URL that will
# point the browser at the initial page that wanted to access the OAuth2
# protected resource.
# @param state (OAuth2 RFC) encapsulates the state of the calling page (URL + params) to return
def callback_page_redirect_uri(state)
# In the current implementation "state" just consists of the URL of
# the initial page, possibily with "&var=value" added parameters.
# token is stored with the OAuth2::Manager, so we don't need it here.
# So we can just return this page.
state
end
# Called by callback_page with a cryptographic "code" that indicates
# that the user has successfully authorized the OAuth2 provider.
# We now are going to exchange this code to a token (bearer+refresh)
def code_to_token(code)
# Return a Rack::OAuth2::AccessToken::Bearer or an error string
service_result = request_new_token(authorization_code: code)
return service_result unless service_result.success?
# Create a new OAuthClientToken from Rack::OAuth::AccessToken::Bearer and return
ServiceResult.new(
success: true,
result: create_new_oauth_client_token(service_result.result)
)
end
private
# Check if a OAuthClientToken already exists and return nil otherwise.
# Don't handle the case of an expired token.
def get_existing_token
# Check if we've got a token in the database and return nil otherwise.
OAuthClientToken.find_by(user_id: @user, oauth_client_id: @oauth_client.id)
end
# Calls client.access_token!
# Convert the various exceptions into user-friendly error strings.
def request_new_token(options = {})
rack_access_token = rack_oauth_client(options)
.access_token!(:body) # Rack::OAuth2::AccessToken
ServiceResult.new(success: true,
result: rack_access_token)
rescue Rack::OAuth2::Client::Error => e # Handle Rack::OAuth2 specific errors
service_result_with_error(i18n_rack_oauth2_error_message(e))
rescue Timeout::Error, EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError,
Errno::EINVAL, Errno::ENETUNREACH, Errno::ECONNRESET, Errno::ECONNREFUSED, JSON::ParserError => e
service_result_with_error(
"#{I18n.t('oauth_client.errors.oauth_returned_http_error')}: #{e.class}: #{e.message.to_html}"
)
rescue StandardError => e
service_result_with_error(
"#{I18n.t('oauth_client.errors.oauth_returned_standard_error')}: #{e.class}: #{e.message.to_html}"
)
end
# Localize the error message
def i18n_rack_oauth2_error_message(rack_oauth2_client_exception)
l10n_key = "oauth_client.errors.rack_oauth2.#{rack_oauth2_client_exception.message}"
if I18n.exists? l10n_key
I18n.t(l10n_key)
else
"#{I18n.t('oauth_client.errors.oauth_returned_error')}: #{rack_oauth2_client_exception.message.to_html}"
end
end
# Return a fully configured RackOAuth2Client.
# This client does all the heavy lifting with the OAuth2 protocol.
def rack_oauth_client(options = {})
oauth_client_uri = URI.parse(@oauth_client.integration.host)
oauth_client_scheme = oauth_client_uri.scheme
oauth_client_host = oauth_client_uri.host
oauth_client_port = oauth_client_uri.port
client = Rack::OAuth2::Client.new(
identifier: @oauth_client.client_id,
secret: @oauth_client.client_secret,
scheme: oauth_client_scheme,
host: oauth_client_host,
port: oauth_client_port,
authorization_endpoint: "/apps/oauth2/authorize",
token_endpoint: "/apps/oauth2/api/v1/token"
)
# Write options, for example authorization_code and refresh_token
client.refresh_token = options[:refresh_token] if options[:refresh_token]
client.authorization_code = options[:authorization_code] if options[:authorization_code]
client
end
# Create a new OpenProject token object based on the return values
# from a Rack::OAuth2::AccessToken::Bearer token
def create_new_oauth_client_token(rack_access_token)
OAuthClientToken.create(
user: @user,
oauth_client: @oauth_client,
origin_user_id: rack_access_token.raw_attributes[:user_id], # ID of user at OAuth2 provider
access_token: rack_access_token.access_token,
token_type: rack_access_token.token_type, # :bearer
refresh_token: rack_access_token.refresh_token,
expires_in: rack_access_token.raw_attributes[:expires_in],
scope: rack_access_token.scope, # nil
state: "undefined"
)
end
# Update an OpenProject token based on updated values from a
# Rack::OAuth2::AccessToken::Bearer after a OAuth2 refresh operation
# ToDo: copy into service
def update_oauth_client_token(oauth_client_token, rack_oauth2_access_token)
success = oauth_client_token.update(
access_token: rack_oauth2_access_token.access_token,
refresh_token: rack_oauth2_access_token.refresh_token,
expires_in: rack_oauth2_access_token.expires_in
)
if success
ServiceResult.new(success: true, result: oauth_client_token)
else
result = ServiceResult.new(success: false)
result.errors.add(:base, I18n.t('oauth_client.errors.refresh_token_updated_failed'))
result.add_dependent!(ServiceResult.new(success: false, errors: oauth_client_token.errors))
result
end
end
# Shortcut method to convert an error message into an unsuccessful
# ServiceResult with that error message
def service_result_with_error(message)
ServiceResult.new(success: false).tap do |result|
result.errors.add(:base, message)
end
end
end
end

@ -0,0 +1,39 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
# The logic for creating storage was extracted from the controller and put into
# a service: https://dev.to/joker666/ruby-on-rails-pattern-service-objects-b19
# Purpose: create and persist a Storages::Storage record
# Used by: Storages::Admin::StoragesController#create, could also be used by the
# API in the future.
# Reference: https://www.openproject.org/docs/development/concepts/contracted-services/
# The comments here are also valid for the other *_service.rb files
module OAuthClients
class CreateService < ::BaseServices::Create
end
end

@ -0,0 +1,33 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
# See also: create_service.rb for comments
module OAuthClients
class DeleteService < ::BaseServices::Delete
end
end

@ -0,0 +1,31 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
# See also: create_service.rb for comments
class OAuthClients::SetAttributesService < ::BaseServices::SetAttributes
end

@ -3205,4 +3205,44 @@ en:
revoke_my_application_confirmation: "Do you really want to remove this application? This will revoke %{token_count} active for it."
my_registered_applications: "Registered OAuth applications"
oauth_client:
labels:
label_oauth_integration: "OAuth2 integration"
label_redirect_uri: "Redirect URI"
label_request_token: "Request token"
label_refresh_token: "Refresh token"
errors:
oauth_was_a_mess: "OAuth2 returned an error"
oauth_reported: "OAuth2 provider reported"
oauth_returned_error: "OAuth2 returned an error"
oauth_returned_json_error: "OAuth2 returned a JSON error"
oauth_returned_http_error: "OAuth2 returned a network error"
oauth_returned_standard_error: "OAuth2 returned an internal error"
wrong_token_type_returned: "OAuth2 returned a wrong type of token, expecting AccessToken::Bearer"
oauth_issue_contact_admin: "OAuth2 reported an error. Please contact your system administrator."
oauth_client_not_found: "OAuth2 client not found in 'callback' endpoint (redirect_uri)."
refresh_token_called_without_existing_token: >
Internal error: Called refresh_token without a previously existing token.
refresh_token_updated_failed: "Error during update of OAuthClientToken"
oauth_client_not_found_explanation: >
This error appears after you have updated the client_id and client_secret
in OpenProject, but haven't updated the 'Return URI' field in the OAuth2 provider.
oauth_code_not_present: "OAuth2 'code' not found in 'callback' endpoint (redirect_uri)."
oauth_code_not_present_explanation: >
This error appears if you have selected the wrong response_type
in the OAuth2 provider. Response_type should be 'code' or similar.
oauth_state_not_present: "OAuth2 'state' not found in 'callback' endpoint (redirect_uri)."
oauth_state_not_present_explanation: >
The 'state' is used to indicate to OpenProject where to continue
after a successful OAuth2 authentication.
A missing 'state' is an internal error that may appear during setup.
Please contact your system administrator.
rack_oauth2:
client_secret_invalid: "Client secret is invalid"
invalid_request: >
OAuth2 server responded with 'invalid_request'.
This error appears if you try to authorize multiple times.
invalid_response: "OAuth2 server provided an invalid response"
you: you

@ -1289,3 +1289,7 @@ en:
remove: 'Remove'
drop_modal:
Close: 'Close'
storages:
enter_oauth2_client_secret: 'Please enter your OAuth2 client secret'

@ -580,6 +580,12 @@ OpenProject::Application.routes.draw do
get '(/*state)', to: 'angular#notifications_layout', as: :notifications_center
end
# OAuthClient needs a "callback" URL that Nextcloud calls with a "code" (see OAuth2 RFC)
scope 'oauth_clients/:id' do
get 'callback', controller: 'oauth_clients', action: :callback
get 'refresh', controller: 'oauth_clients', action: :refresh
end
# Routes for design related documentation and examples pages
get '/design/spot', to: 'angular#empty_layout'
get '/design/styleguide' => redirect('/assets/styleguide.html')

@ -0,0 +1,40 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
class CreateOAuthClient < ActiveRecord::Migration[6.1]
def change
create_table :oauth_clients do |t|
t.string :client_id, null: false
t.string :client_secret, null: false
t.references :integration,
polymorphic: true, index: { unique: true }
t.timestamps
end
end
end

@ -0,0 +1,19 @@
class CreateOAuthClientTokens < ActiveRecord::Migration[6.1]
def change
create_table :oauth_client_tokens do |t|
t.references :oauth_client, null: false, foreign_key: { to_table: :oauth_clients, on_delete: :cascade }
t.references :user, null: false, index: true, foreign_key: { to_table: :users, on_delete: :cascade }
t.string :access_token
t.string :refresh_token
t.string :token_type
t.integer :expires_in
t.string :scope
t.string :state
t.string :origin_user_id # ID of the current user on the _OAuth2_provider_side_
t.timestamps
t.index %i[user_id oauth_client_id], unique: true
end
end
end

@ -0,0 +1,109 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
class Storages::Admin::OAuthClientsController < ApplicationController
# See https://guides.rubyonrails.org/layouts_and_rendering.html for reference on layout
layout 'admin'
# Before executing any action below: Make sure the current user is an admin
# and set the @<controller_name> variable to the object referenced in the URL.
before_action :require_admin
before_action :find_storage
before_action :delete_current_oauth_client, only: %i[create]
# menu_item is defined in the Redmine::MenuManager::MenuController
# module, included from ApplicationController.
# The menu item is defined in the engine.rb
menu_item :storages_admin_settings
# Show the admin page to create a new OAuthClient object.
def new
@oauth_client = ::OAuthClients::SetAttributesService.new(user: User.current,
model: OAuthClient.new,
contract_class: EmptyContract)
.call
.result
render '/storages/admin/storages/new_oauth_client'
end
# Actually create a OAuthClient object.
# Use service pattern to create a new OAuthClient
# See also: https://www.openproject.org/docs/development/concepts/contracted-services/
# Called by: Global app/config/routes.rb to serve Web page
def create
service_result = ::OAuthClients::CreateService.new(user: User.current)
.call(permitted_oauth_client_params.merge(integration: @storage))
@oauth_client = service_result.result
if service_result.success?
flash[:notice] = I18n.t(:notice_successful_create)
# admin_settings_storage_path is automagically created by Ruby routes.
redirect_to admin_settings_storage_path(@storage)
else
@errors = service_result.errors
render '/storages/admin/storages/new_oauth_client'
end
end
# Used by: admin layout
# Breadcrumbs is something like OpenProject > Admin > Storages.
# This returns the name of the last part (Storages admin page)
def default_breadcrumb
ActionController::Base.helpers.link_to(t('storages.label_oauth_client_details'), admin_settings_storage_oauth_client_path)
end
# See: default_breadcrumb above
# Defines whether to show breadcrumbs on the page or not.
def show_local_breadcrumb
true
end
private
def ensure_storages_module_active
return if OpenProject::FeatureDecisions.storages_module_active?
raise ActionController::RoutingError, 'Not Found'
end
# Called by create and update above in order to check if the
# update parameters are correctly set.
def permitted_oauth_client_params
params
.require(:oauth_client)
.permit('client_id', 'client_secret')
end
def find_storage
@storage = ::Storages::Storage.find(params[:storage_id])
end
def delete_current_oauth_client
::OAuthClients::DeleteService.new(user: User.current, model: @storage.oauth_client).call if @storage.oauth_client
end
end

@ -43,6 +43,8 @@ class Storages::Admin::StoragesController < ApplicationController
before_action :require_admin
before_action :find_model_object, only: %i[show destroy edit update]
before_action :set_shortened_secret, only: %i[show edit update]
# menu_item is defined in the Redmine::MenuManager::MenuController
# module, included from ApplicationController.
# The menu item is defined in the engine.rb
@ -56,7 +58,18 @@ class Storages::Admin::StoragesController < ApplicationController
# Show page with details of one Storage object.
# Called by: Global app/config/routes.rb to serve Web page
def show; end
def show
# Start the OAuth2 manager that will handle all the rest
oauth_client = @object.oauth_client
connection_manager = OAuthClients::ConnectionManager.new(user: User.current, oauth_client:)
# The URL for the "callback" endpoint, including the the ID of the OAuthClient object
# We use 1+id because a new oauth_client will be created after enter the client_id + client_secret
# from the Nextcloud side.
@redirect_url = "#{request.protocol}#{request.host_with_port}/oauth_clients/#{1 + oauth_client.id}/callback"
scope = nil # Nextcloud doesn't have multiple scopes.
state = "#{request.protocol}#{request.host_with_port}/projects/"
@authorize_url = connection_manager.redirect_to_oauth_authorize(scope:, state:)
end
# Show the admin page to create a new Storage object.
# Sets the attributes provider_type and name as default values and then
@ -89,8 +102,12 @@ class Storages::Admin::StoragesController < ApplicationController
if service_result.success?
flash[:notice] = I18n.t(:notice_successful_create)
# admin_settings_storage_path is automagically created by Ruby routes.
redirect_to admin_settings_storage_path(@object)
if @object.oauth_client
# admin_settings_storage_path is automagically created by Ruby routes.
redirect_to admin_settings_storage_path(@object)
else
redirect_to new_admin_settings_storage_oauth_client_path(@object)
end
else
@errors = service_result.errors
render :new
@ -135,6 +152,15 @@ class Storages::Admin::StoragesController < ApplicationController
redirect_to admin_settings_storages_path
end
# Show first two and last two characters, with **** in the middle
def shortened_secret(secret)
result = ""
if secret.is_a?(String) && secret.present?
result = "#{secret[...2]}****#{secret[-2...]}"
end
result
end
# Used by: admin layout
# Breadcrumbs is something like OpenProject > Admin > Storages.
# This returns the name of the last part (Storages admin page)
@ -165,6 +191,10 @@ class Storages::Admin::StoragesController < ApplicationController
def permitted_storage_params
params
.require(:storages_storage)
.permit('name', 'provider_type', 'host')
.permit('name', 'provider_type', 'host', 'oauth_client_id', 'oauth_client_secret')
end
def set_shortened_secret
@short_secret = shortened_secret(@object.oauth_client&.client_secret.to_s)
end
end

@ -51,6 +51,8 @@ class Storages::Storage < ApplicationRecord
has_many :projects_storages, dependent: :destroy, class_name: 'Storages::ProjectStorage'
# We can get the list of projects with this Storage enabled.
has_many :projects, through: :projects_storages
# A storage authenticates at a storage via OAuth.
has_one :oauth_client, as: :integration, dependent: :destroy
PROVIDER_TYPES = [
PROVIDER_TYPE_NEXTCLOUD = 'nextcloud'.freeze

@ -31,32 +31,30 @@ See COPYRIGHT and LICENSE files for more details.
<%= error_messages_for_contract @object, @errors %>
<section class="form--section">
<div class="form--field -required">
<%= f.select :provider_type,
::Storages::Storage::PROVIDER_TYPES.map { |provider_type| [I18n.t("storages.provider_types.#{provider_type}"), provider_type] },
{
selected: ::Storages::Storage::PROVIDER_TYPE_NEXTCLOUD,
container_class: '-slim'
},
{
disabled: @object.persisted? || ::Storages::Storage::PROVIDER_TYPES.count == 1
} %>
</div>
<div class="form--field -required">
<%= f.text_field :name, required: true, container_class: '-slim' %>
<span class="form--field-instructions">
<%= t('storages.instructions.name') %>
</span>
</div>
<div class="form--field -required">
<%= f.text_field :host,
type: :url,
required: true,
pattern: ".{1,255}",
placeholder: "https://my-file-storage.com", container_class: '-wide' %>
<span class="form--field-instructions">
<%= t('storages.instructions.host') %>
</span>
</div>
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= I18n.t(:label_general) %></legend>
<div class="form--field -required">
<%= f.select :provider_type,
::Storages::Storage::PROVIDER_TYPES.map { |provider_type| [I18n.t("storages.provider_types.#{provider_type}"), provider_type] },
{
selected: ::Storages::Storage::PROVIDER_TYPE_NEXTCLOUD,
container_class: '-slim'
},
{
disabled: @object.persisted? || ::Storages::Storage::PROVIDER_TYPES.count == 1
} %>
</div>
<div class="form--field -required">
<%= f.text_field :name, required: true, container_class: '-slim' %>
<span class="form--field-instructions"><%= t('storages.instructions.name') %></span>
</div>
<div class="form--field -required">
<%= f.text_field :host,
type: :url,
required: true,
pattern: ".{1,255}",
placeholder: "https://my-file-storage.com", container_class: '-wide' %>
<span class="form--field-instructions"><%= t('storages.instructions.host') %></span>
</div>
</fieldset>
</section>

@ -1,4 +1,10 @@
<!-- Standard Ruby view, please see the controller for comments -->
<!-- We want to hide client_secret from the user while allowing the user to edit the other attributes -->
<% content_for :header_tags do %>
<meta name="required_script" content="storage_form" />
<% end %>
<% html_title t(:label_administration), t("project_module_storages"), t('label_edit_x', x: @object.name) %>
<% local_assigns[:additional_breadcrumb] = @object.name %>
<%= toolbar title: t('label_edit_x', x: @object.name) %>
@ -7,3 +13,31 @@
<%= render partial: 'form', locals: { f: f } %>
<%= styled_button_tag t(:button_save), class: "-highlight -with-icon icon-checkmark" %>
<% end %>
<section class="form--section">
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= t("storages.provider_types.#{@object.provider_type}") %> <%= t(:'storages.label_oauth_client_details') %></legend>
<% if @object.oauth_client %>
<div class="attributes-key-value">
<div class="attributes-key-value--key"><%= t("storages.provider_types.#{@object.provider_type}") %> <%= t(:'storages.label_oauth_client_id') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @object.oauth_client.client_id %></span>
</div>
</div>
<div class="attributes-key-value--key"><%= t("storages.provider_types.#{@object.provider_type}") %> <%= t(:'storages.label_oauth_client_secret') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @short_secret %></span>
</div>
</div>
</div>
<%= link_to(t("button_replace"),
new_admin_settings_storage_oauth_client_path(@object),
data: { confirm: t(:'storages.confirm_replace_oauth_client')},
class: 'button -with-icon icon-reload' ) %>
<% else %>
<%= link_to(t("js.label_create"), new_admin_settings_storage_oauth_client_path(@object), class: 'button -with-icon icon-add') %>
<% end %>
</fieldset>
</section>

@ -5,5 +5,9 @@
<%= labelled_tabular_form_for @object, url: admin_settings_storages_path(@object) do |f| -%>
<%= render partial: 'form', locals: { f: f } %>
<%= styled_button_tag t(:button_create), class: "-highlight -with-icon icon-checkmark" %>
<% if @object.oauth_client %>
<%= styled_button_tag t(:button_save), class: "-highlight -with-icon icon-checkmark" %>
<% else %>
<%= styled_button_tag t(:button_continue), class: "-highlight -with-icon icon-arrow-right3" %>
<% end %>
<% end %>

@ -0,0 +1,38 @@
<!-- Standard Ruby view, please see the controller for comments -->
<% html_title t(:label_administration), t("project_module_storages"), @storage.name, "#{t("storages.provider_types.#{@storage.provider_type}")} #{t("storages.label_oauth_client_details")}" %>
<% local_assigns[:additional_breadcrumb] = "#{t("storages.provider_types.#{@storage.provider_type}")} #{t("storages.label_oauth_client_details")}" %>
<%= toolbar title: "#{t("storages.provider_types.#{@storage.provider_type}")} #{t("storages.label_oauth_client_details")}" %>
<%= labelled_tabular_form_for @oauth_client, url: admin_settings_storage_oauth_client_path do |f| -%>
<div class="form--field -required">
<%= f.text_field :client_id,
label: t('storages.label_oauth_client_id'),
required: true,
size: 40,
container_class: '-wide' %>
<span class="form--field-instructions">
<%= t("storages.instructions.#{@storage.provider_type}.oauth_client_id") %>
<%= link_to "#{t("storages.provider_types.#{@storage.provider_type}")} / #{t("storages.instructions.#{@storage.provider_type}.administration")} / #{t("storages.instructions.#{@storage.provider_type}.oauth2_clients")}",
URI::join(@storage.host, "settings/admin/security#oauth2").to_s,
target: "blank" %>
</span>
</div>
<div class="form--field -required">
<%= f.text_field :client_secret,
label: t('storages.label_oauth_client_secret'),
required: true,
size: 40,
container_class: '-wide' %>
<span class="form--field-instructions">
<%= t("storages.instructions.#{@storage.provider_type}.oauth_client_secret") %>
<%= link_to "#{t("storages.provider_types.#{@storage.provider_type}")} / #{t("storages.instructions.#{@storage.provider_type}.administration")} / #{t("storages.instructions.#{@storage.provider_type}.oauth2_clients")}",
URI::join(@storage.host, "settings/admin/security#oauth2").to_s,
target: "blank" %>
</span>
</div>
<% if @storage.oauth_client %>
<%= styled_button_tag t(:button_replace), class: "-highlight -with-icon icon-checkmark" %>
<% else %>
<%= styled_button_tag t(:button_save), class: "-highlight -with-icon icon-checkmark" %>
<% end %>
<% end %>

@ -50,37 +50,121 @@ See COPYRIGHT and LICENSE files for more details.
</li>
<% end %>
<div class="attributes-group">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text"><%= t(:label_general) %></h3>
</div>
</div>
<div class="attributes-key-value">
<div class="attributes-key-value--key"><%= t(:'storages.label_name') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @object.name %></span>
</div>
<div class="attributes-key-value--value -text"><span><%= @object.name %></span></div>
</div>
<div class="attributes-key-value--key"><%= t(:'storages.label_provider_type') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= t(:"storages.provider_types.#{@object.provider_type}") %></span>
</div>
</div>
<div class="attributes-key-value--key"><%= t(:'storages.label_host') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= link_to @object.host, @object.host %></span>
</div>
</div>
<div class="attributes-key-value--key"><%= t(:'storages.label_creator') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @object.creator.name %></span>
</div>
</div>
<div class="attributes-key-value--key"><%= Storages::ProjectStorage.human_attribute_name(:created_at) %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= format_time(@object.created_at) %></span>
</div>
</div>
</div>
</div>
<div class="attributes-group">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text"><%= t("storages.provider_types.#{@object.provider_type}") %> <%= t(:'storages.label_oauth_client_details') %></h3>
</div>
</div>
<% if @object.oauth_client %>
<div class="attributes-key-value">
<div class="attributes-key-value--key"><%= t("storages.provider_types.#{@object.provider_type}") %> <%= t(:'storages.label_oauth_client_id') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @object.oauth_client.client_id %></span>
</div>
</div>
<div class="attributes-key-value--key"><%= t("storages.provider_types.#{@object.provider_type}") %> <%= t(:'storages.label_oauth_client_secret') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @short_secret %></span>
</div>
</div>
</div>
<% else %>
<%= t("storages.oauth_client_details_missing") %>
<% end %>
</div>
<div class="attributes-group">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text"><%= t(:'oauth_client.labels.label_oauth_integration') %></h3>
</div>
</div>
<div class="attributes-key-value">
<div class="attributes-key-value--key"><%= t(:'oauth_client.labels.label_redirect_uri') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @redirect_url %></span>
</div>
</div>
<div class="attributes-key-value--key"><%= t(:'oauth_client.labels.label_request_token') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span>
<%= link_to @authorize_url, target: "_blank", class: 'button' do %>
<%= op_icon('button--icon') %>
<span class="button--text"><%= "Request OAuth2 Token" %></span>
<% end %>
</span>
</div>
</div>
<div class="attributes-key-value--key"><%= t(:'oauth_client.labels.label_refresh_token') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span>
<%= link_to "#{request.protocol}#{request.host_with_port}/oauth_clients/#{@object.oauth_client.id}/refresh", target: "_blank", class: 'button' do %>
<%= op_icon('button--icon') %>
<span class="button--text"><%= "Refresh OAuth2 Token" %></span>
<% end %>
</span>
</div>
</div>
</div>
</div>

@ -49,6 +49,11 @@ en:
setting_up_storages: "For setting up file storages, please visit"
setting_up_storages_non_admin: "Administrators can set up file storages in Administration / File Storages."
all_available_storages_already_added: "All available storages are already added to the project."
nextcloud:
administration: Administration
oauth2_clients: OAuth2 clients
oauth_client_id: Copy the value from
oauth_client_secret: Copy the value from
delete_warning:
storage: >
Are you sure you want to delete this storage? This will also delete the storage from all projects where it is used.
@ -61,11 +66,16 @@ en:
label_file_links: "File links"
label_name: "Name"
label_host: "Host"
label_oauth_client_details: "OAuth client details"
label_provider_type: "Provider type"
label_new_storage: "New storage"
label_storage: "Storage"
label_storages: "Storages"
no_results: "No storages set up, yet."
label_oauth_client_id: "OAuth Client ID"
label_oauth_client_secret: "OAuth Client Secret"
provider_types:
label: "Provider type"
nextcloud: "Nextcloud"
confirm_replace_oauth_client: "Are you sure? All users will have to authorize again against the storage."
oauth_client_details_missing: "To complete the setup, please add OAuth client credentials from your storage."

@ -29,7 +29,9 @@
OpenProject::Application.routes.draw do
namespace :admin do
namespace :settings do
resources :storages, controller: '/storages/admin/storages'
resources :storages, controller: '/storages/admin/storages' do
resource :oauth_client, controller: '/storages/admin/oauth_clients', only: %i[new create]
end
end
end

@ -37,53 +37,130 @@ describe 'Admin storages', :enable_storages, :storage_server_helpers, type: :fea
it 'creates, edits and deletes storages', webmock: true do
visit admin_settings_storages_path
# Show empty storages list
expect(page).to have_title('File storages')
expect(page.find('.title-container')).to have_text('File storages')
expect(page).to have_text(I18n.t('storages.no_results'))
page.find('.toolbar .button--icon.icon-add').click
# Create a storage - happy path
expect(page).to have_title('New storage')
expect(page.find('.title-container')).to have_text('New storage')
expect(page).to have_select 'storages_storage[provider_type]', selected: 'Nextcloud', disabled: true
expect(page).to have_field('storages_storage[name]', with: 'Nextcloud')
# Test the happy path for a valid storage server (host).
# Mock a valid response (=200) for example.com, so the host validation should succeed
mock_server_capabilities_response("https://example.com")
page.find('#storages_storage_name').set("NC 1")
page.find('#storages_storage_host').set("https://example.com")
page.find('button[type=submit]').click
page.find('button[type=submit]', text: "Continue").click
# Add OAuthClient - Testing a number of different invalid states
# However, more detailed checks are performed in the service spec.
expect(page).to have_title("OAuth client details")
# Set the client_id but leave client_secret empty
page.find('#oauth_client_client_id').set("0123456789")
page.find('button[type=submit]').click
# Check that we're still on the same page
expect(page).to have_title("OAuth client details")
# Set client_id to be empty but set the client_secret
page.find('#oauth_client_client_id').set("")
page.find('#oauth_client_client_secret').set("1234567890")
page.find('button[type=submit]', text: 'Save').click
# Check that we're still on the same page
expect(page).to have_title("OAuth client details")
# Both client_id and client_secret valid
page.find('#oauth_client_client_id').set("0123456789")
page.find('#oauth_client_client_secret').set("1234567890")
page.find('button[type=submit]', text: 'Save').click
# Show details of a storage
created_storage = Storages::Storage.find_by(name: 'NC 1')
expect(page).to have_title("Nc 1")
expect(page.find('.title-container')).to have_text('NC 1')
expect(page).to have_text(admin.name)
expect(page).to have_text('https://example.com')
expect(page).to have_text(created_storage.created_at.localtime.strftime("%m/%d/%Y %I:%M %p"))
# Check for client_id and the shortened client secret
expect(page).to have_text("0123456789")
expect(page).to have_text("12****90")
# Edit storage again
page.find('.button--icon.icon-edit').click
expect(page).to have_title("Edit: NC 1")
expect(page.find('.title-container')).to have_text('Edit: NC 1')
mock_server_capabilities_response("https://other.example.com")
# Edit page - With option to replace the OAuth2 client
# Check presence of a "Replace" link and follow it
page.find('a', text: 'Replace').click
alert_text = page.driver.browser.switch_to.alert.text
expect(alert_text).to have_text("Are you sure?")
page.driver.browser.switch_to.alert.accept
# The form the new OAuth client shall be empty as we are creating a new one.
expect(page).not_to have_text("234567")
expect(page).not_to have_text("****")
page.find('#oauth_client_client_id').set("2345678901")
page.find('#oauth_client_client_secret').set("3456789012")
page.find('button[type=submit]', text: 'Replace').click
# Check for client_id and the shortened client secret
expect(page).to have_text("2345678901")
expect(page).to have_text("34****12")
# Test the behavior of a failed host validation with code 400 (Bad Request)
# simulating server not running Nextcloud
page.find('.button--icon.icon-edit').click
mock_server_capabilities_response("https://other.example.com", response_code: '400')
page.find('#storages_storage_name').set("Other NC")
page.find('#storages_storage_host').set("https://other.example.com")
page.find('button[type=submit]').click
page.find('button[type=submit]', text: "Save").click
expect(page).to have_title("Edit: Other NC")
expect(page.find('.title-container')).to have_text('Edit: Other NC')
expect(page).to have_selector('.op-toast--content')
expect(page).to have_text("error prohibited this Storage from being saved")
# Edit page - Check for failed Nextcloud Version
# Test the behavior of a Nextcloud server with major version too low
mock_server_capabilities_response("https://old.example.com", response_nextcloud_major_version: 18)
page.find('#storages_storage_name').set("Old NC")
page.find('#storages_storage_host').set("https://old.example.com")
page.find('button[type=submit]', text: "Save").click
expect(page).to have_title("Edit: Old NC")
expect(page).to have_selector('.op-toast')
version_err = I18n.t('activerecord.errors.models.storages/storage.attributes.host.minimal_nextcloud_version_unmet')
expect(page).to have_text(version_err)
# Edit page - save working storage
# Restore the mocked working server example.com
page.find('#storages_storage_host').set("https://example.com")
page.find('#storages_storage_name').set("Other NC")
page.find('button[type=submit]', text: "Save").click
created_storage = Storages::Storage.find_by(name: 'Other NC')
expect(page).to have_title("Other Nc")
expect(page.find('.title-container')).to have_text('Other NC')
expect(page).to have_text('https://other.example.com')
expect(page).to have_text(admin.name)
expect(page).to have_text('https://example.com')
expect(page).to have_text(created_storage.created_at.localtime.strftime("%m/%d/%Y %I:%M %p"))
# List of storages
page.find("ul.op-breadcrumb li", text: "File storages").click
expect(page).to have_title "File storages"
expect(page.find('.title-container')).to have_text('File storages')
expect(page).to have_text('Other NC')
expect(page).to have_text('https://other.example.com')
expect(page).to have_text(admin.name)
# Go to Other NC again
page.find("a", text: 'Other NC').click
expect(page).to have_current_path admin_settings_storage_path(created_storage)
# Delete on List page
page.find('.button--icon.icon-delete').click
alert_text = page.driver.browser.switch_to.alert.text
@ -92,5 +169,7 @@ describe 'Admin storages', :enable_storages, :storage_server_helpers, type: :fea
expect(page).to have_current_path(admin_settings_storages_path)
expect(page).not_to have_text("Other NC")
# Also check that there are no more OAuthClient instances anymore
expect(OAuthClient.all.count).to eq(0)
end
end

@ -0,0 +1,90 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
require 'spec_helper'
require_module_spec_helper
require 'contracts/shared/model_contract_shared_context'
describe ::OAuthClients::CreateContract do
include_context 'ModelContract shared context'
let(:current_user) { create(:admin) }
let(:client_id) { "1234567889" }
let(:client_secret) { "asdfasdfasdf" }
let(:integration) { build_stubbed :storage }
let(:oauth_client) do
build :oauth_client,
client_id: client_id,
client_secret: client_secret,
integration: integration
end
let(:contract) { described_class.new(oauth_client, current_user) }
it_behaves_like 'contract is valid for active admins and invalid for regular users'
describe 'validations' do
context 'when all attributes are valid' do
include_examples 'contract is valid'
end
%i[client_id client_secret].each do |attribute_name|
context 'when client_id is invalid' do
context 'as it is too long' do
let(attribute_name) { 'X' * 257 }
include_examples 'contract is invalid', attribute_name => :too_long
end
context 'as it is empty' do
let(attribute_name) { '' }
include_examples 'contract is invalid', attribute_name => :blank
end
context 'as it is nil' do
let(attribute_name) { nil }
include_examples 'contract is invalid', attribute_name => :blank
end
end
end
context 'with integration (polymorphic attribute) linked' do
let(:integration) { create :storage }
include_examples 'contract is valid'
end
context 'without integration (polymorphic attribute)' do
let(:integration) { nil }
include_examples 'contract is invalid', { integration_id: :blank, integration_type: :blank }
end
end
end

@ -0,0 +1,43 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
require 'spec_helper'
require_module_spec_helper
require 'contracts/shared/model_contract_shared_context'
# This DeleteContract spec just tests if the user is _allowed_
# to execute the operation.
describe ::OAuthClients::DeleteContract do
include_context 'ModelContract shared context'
let(:oauth_client) { create :oauth_client }
let(:contract) { described_class.new(oauth_client, current_user) }
# Generic checks that the contract is valid for valid admin, but invalid otherwise
it_behaves_like 'contract is valid for active admins and invalid for regular users'
end

@ -0,0 +1,35 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
FactoryBot.define do
factory :oauth_client, class: '::OAuthClient' do
sequence(:client_id) { |n| "1234567890-#{n}" }
sequence(:client_secret) { |n| "2345678901-#{n}" }
integration factory: :storage
end
end

@ -0,0 +1,36 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
FactoryBot.define do
factory :oauth_client_token, class: '::OAuthClientToken' do
sequence(:access_token) { |n| "1234567890-#{n}" }
sequence(:refresh_token) { |n| "2345678901-#{n}" }
oauth_client factory: :oauth_client
user factory: :user
end
end

@ -0,0 +1,111 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
require 'spec_helper'
describe OAuthClientToken, type: :model do
let(:access_token) { "x" }
let(:refresh_token) { "x" }
let(:user) { create :user }
let(:oauth_client) { create :oauth_client }
let(:instance) { described_class.new(access_token:, refresh_token:, user:, oauth_client:) }
describe '#valid?' do
subject { instance.valid? }
context 'with default arguments' do
it 'succeeds' do
expect(subject).to be_truthy
end
end
context 'with access_token too long' do
let(:access_token) { "x" * 257 }
it 'fails with access_token too long' do
expect(subject).to be_falsey
end
end
context 'with refresh_token too long' do
let(:refresh_token) { "x" * 257 }
it 'fails with refresh_token too long' do
expect(subject).to be_falsey
end
end
context 'with access_token too short' do
let(:access_token) { "" }
it 'fails with access_token too short' do
expect(subject).to be_falsey
end
end
context 'with refresh_token too short' do
let(:refresh_token) { "" }
it 'fails with refresh_token too short' do
expect(subject).to be_falsey
end
end
context 'without access_token' do
let(:access_token) { nil }
it 'fails with access_token is nil' do
expect(subject).to be_falsey
end
end
context 'without refresh_token' do
let(:refresh_token) { nil }
it 'fails with refresh_token is nil' do
expect(subject).to be_falsey
end
end
context 'with invalid user' do
let(:user) { nil }
it 'fails with inavlid user' do
expect(subject).to be_falsey
end
end
context 'with invalid oauth_client' do
let(:oauth_client) { nil }
it 'fails with invalid oauth_client' do
expect(subject).to be_falsey
end
end
end
end

@ -0,0 +1,337 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
require 'spec_helper'
require 'webmock/rspec'
describe ::OAuthClients::ConnectionManager, type: :model do
let(:user) { create :user }
let(:host_ip) { "172.16.193.144" }
let(:host_proto) { "http://" }
let(:host_port) { "" }
let(:host) { "#{host_proto}#{host_ip}#{host_port}" }
let(:provider_type) { ::Storages::Storage::PROVIDER_TYPE_NEXTCLOUD }
let(:storage) { create(:storage, provider_type:, host: "#{host}/") }
let(:scope) { [:all] } # OAuth2 resources to access, specific to provider
let(:oauth_client) do
create(:oauth_client,
client_id: "nwz34rWsolvJvchfQ1bVHXfMb1ETK89lCBgzrLhWx3ACW5nKfmdcyf5ftlCyKGbk",
client_secret: "A08n6CRBOOr41iqkWRynnP6BbmEnau7LeP9t9xrIbiYX46iXgmIZgqhJoDFjUMEq",
integration: storage)
end
let(:oauth_client_token) { create(:oauth_client_token, oauth_client:, user:) }
let(:instance) { described_class.new(user:, oauth_client:) }
# Test the redirect_to_oauth_authorize function that puts together
# the OAuth2 provider URL (Nextcloud) according to RFC specs.
describe '#redirect_to_oauth_authorize' do
let(:scope) { nil }
let(:state) { nil }
subject { instance.redirect_to_oauth_authorize(scope:, state:) }
context 'with empty state and scope' do
it 'returns the redirect URL' do
expect(subject).to be_a String
expect(subject).to include oauth_client.integration.host
expect(subject).not_to include "scope"
expect(subject).not_to include "state"
end
end
context 'with state but empty scope' do
let(:state) { "https://example.com/page" }
it 'returns the redirect URL' do
expect(subject).to be_a String
expect(subject).to include oauth_client.integration.host
expect(subject).not_to include "scope"
expect(subject).to include "&state=https"
end
end
context 'with multiple scopes but empty state' do
let(:scope) { %i(email profile) }
it 'returns the redirect URL' do
expect(subject).to be_a String
expect(subject).to include oauth_client.integration.host
expect(subject).not_to include "state"
expect(subject).to include "&scope=email%20profile"
end
end
end
# The first step in the OAuth2 flow is to produce a URL for the
# user to authenticate and authorize access at the OAuth2 provider
# (Nextcloud).
describe '#get_access_token' do
subject { instance.get_access_token }
context 'with no OAuthClientToken present' do
it 'returns a redirection URL' do
expect(subject.success).to be_falsey
expect(subject.result).to be_a String
# Details of string are tested above in section #redirect_to_oauth_authorize
end
end
context 'with no OAuthClientToken present and state parameters' do
subject { instance.get_access_token(state: "some_state", scope: [:email]) }
it 'returns the redirect URL' do
expect(subject.success).to be_falsey
expect(subject.result).to be_a String
expect(subject.result).to include oauth_client.integration.host
expect(subject.result).to include "&state=some_state"
expect(subject.result).to include "&scope=email"
end
end
context 'with an OAuthClientToken present' do
before do
oauth_client_token
end
it 'returns the OAuthClientToken' do
expect(subject).to be_truthy
expect(subject.result).to be_a OAuthClientToken # The one and only...
expect(subject.result).to eql oauth_client_token
end
end
end
# In the second step the OAuth2 provider (Nextcloud) redirects
# to a "callback" endpoint on the OAuth2 client (OpenpPoject):
# http://<openproject>:4200/oauth_clients/8/callback?state=&code=7kRGJ...jG3KZ
# This callback code basically just calls code_to_token(code).
# The callback endpoint calls code_to_token(code) with the code
# received and exchanges the code for a bearer+refresh token
# using a HTTP request.
describe '#code_to_token' do
let(:code) { "7kRGJ...jG3KZ" }
subject { instance.code_to_token(code) }
context 'with happy path' do
before do
# Simulate a successful authorization returning the tokens
response_body = {
access_token: "yjTDZ...RYvRH",
token_type: "Bearer",
expires_in: 3600,
refresh_token: "UwFp...1FROJ",
user_id: "admin"
}.to_json
stub_request(:any, File.join(host, '/apps/oauth2/api/v1/token'))
.to_return(status: 200, body: response_body)
end
it 'returns a valid ClientToken object', webmock: true do
expect(subject.success).to be_truthy
expect(subject.result).to be_a OAuthClientToken
end
end
context 'with known reply invalid_request', webmock: true do
before do
stub_request(:post, File.join(host, '/apps/oauth2/api/v1/token'))
.to_return(status: 400, body: { error: "invalid_request" }.to_json)
end
it 'returns a specific error message' do
expect(subject.success).to be_falsey
expect(subject.result).to be_nil
expect(subject.errors[:base].count).to be(1)
expect(subject.errors[:base].first).to include I18n.t('oauth_client.errors.rack_oauth2.invalid_request')
expect(subject.errors[:base].first).not_to include I18n.t('oauth_client.errors.oauth_returned_error')
end
end
context 'with unknown reply', webmock: true do
before do
stub_request(:post, File.join(host, '/apps/oauth2/api/v1/token'))
.to_return(status: 400, body: { error: "invalid_requesttt" }.to_json)
end
it 'returns an unspecific error message' do
expect(subject.success).to be_falsey
expect(subject.result).to be_nil
expect(subject.errors[:base].count).to be(1)
expect(subject.errors[:base].first).to include I18n.t('oauth_client.errors.oauth_returned_error')
end
end
context 'with reply including JSON syntax error', webmock: true do
before do
stub_request(:post, File.join(host, '/apps/oauth2/api/v1/token'))
.to_return(
status: 400,
headers: { 'Content-Type' => 'application/json; charset=utf-8' },
body: "some: very, invalid> <json}"
)
end
it 'returns an unspecific error message' do
expect(subject.success).to be_falsey
expect(subject.result).to be_nil
expect(subject.errors[:base].count).to be(1)
expect(subject.errors[:base].first).to include I18n.t('oauth_client.errors.oauth_returned_error')
end
end
context 'with 500 reply without body', webmock: true do
before do
stub_request(:post, File.join(host, '/apps/oauth2/api/v1/token'))
.to_return(status: 500)
end
it 'returns an unspecific error message' do
expect(subject.success).to be_falsey
expect(subject.result).to be_nil
expect(subject.errors[:base].count).to be(1)
expect(subject.errors[:base].first).to include I18n.t('oauth_client.errors.oauth_returned_error')
end
end
context 'with bad HTTP response', webmock: true do
before do
stub_request(:post, File.join(host, '/apps/oauth2/api/v1/token')).to_raise(Net::HTTPBadResponse)
end
it 'returns an unspecific error message' do
expect(subject.success).to be_falsey
expect(subject.result).to be_nil
expect(subject.errors[:base].count).to be(1)
expect(subject.errors[:base].first).to include I18n.t('oauth_client.errors.oauth_returned_http_error')
end
end
context 'with timeout returns internal error', webmock: true do
before do
stub_request(:post, File.join(host, '/apps/oauth2/api/v1/token')).to_timeout
end
it 'returns an unspecific error message' do
expect(subject.success).to be_falsey
expect(subject.result).to be_nil
expect(subject.errors[:base].count).to be(1)
expect(subject.errors[:base].first).to include I18n.t('oauth_client.errors.oauth_returned_standard_error')
end
end
end
describe '#refresh_token' do
subject { instance.refresh_token }
context 'without preexisting OAuthClientToken' do
it 'returns an error message' do
expect(subject.success).to be_falsey
expect(subject.errors[:base].first)
.to include I18n.t('oauth_client.errors.refresh_token_called_without_existing_token')
end
end
context 'with successful response from OAuth2 provider (happy path)' do
before do
# Simulate a successful authorization returning the tokens
response_body = {
access_token: "xyjTDZ...RYvRH",
token_type: "Bearer",
expires_in: 3601,
refresh_token: "xUwFp...1FROJ",
user_id: "admin"
}.to_json
stub_request(:any, File.join(host, '/apps/oauth2/api/v1/token'))
.to_return(status: 200, body: response_body)
oauth_client_token
end
it 'returns a valid ClientToken object', webmock: true do
expect(subject.success).to be_truthy
expect(subject.result).to be_a OAuthClientToken
expect(subject.result.access_token).to eq("xyjTDZ...RYvRH")
expect(subject.result.refresh_token).to eq("xUwFp...1FROJ")
expect(subject.result.expires_in).to be(3601)
end
end
context 'with invalid access_token data' do
before do
# Simulate a token too long
response_body = {
access_token: "x" * 257, # will fail model validation
token_type: "Bearer",
expires_in: 3601,
refresh_token: "xUwFp...1FROJ",
user_id: "admin"
}.to_json
stub_request(:any, File.join(host, '/apps/oauth2/api/v1/token'))
.to_return(status: 200, body: response_body)
oauth_client_token
end
it 'returns dependent error from model validation', webmock: true do
expect(subject.success).to be_falsey
expect(subject.result).to be_nil
expect(subject.errors.size).to be(1)
puts subject.errors
end
end
context 'with server error from OAuth2 provider' do
before do
stub_request(:any, File.join(host, '/apps/oauth2/api/v1/token'))
.to_return(status: 400, body: { error: "invalid_request" }.to_json)
oauth_client_token
end
it 'returns a server error', webmock: true do
expect(subject.success).to be_falsey
expect(subject.errors.size).to be(1)
puts subject.errors
end
end
context 'with successful response but invalid data' do
before do
# Simulate timeout
stub_request(:any, File.join(host, '/apps/oauth2/api/v1/token'))
.to_timeout
oauth_client_token
end
it 'returns a valid ClientToken object', webmock: true do
expect(subject.success).to be_falsey
expect(subject.result).to be_nil
expect(subject.errors.size).to be(1)
end
end
end
end

@ -0,0 +1,36 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
require 'spec_helper'
require 'services/base_services/behaves_like_create_service'
describe ::OAuthClients::CreateService, type: :model do
it_behaves_like 'BaseServices create service' do
let(:factory) { :oauth_client }
end
end

@ -0,0 +1,36 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
require 'spec_helper'
require 'services/base_services/behaves_like_delete_service'
describe ::OAuthClients::DeleteService, type: :model do
it_behaves_like 'BaseServices delete service' do
let(:factory) { :oauth_client }
end
end

@ -0,0 +1,114 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
require 'spec_helper'
describe ::OAuthClients::SetAttributesService, type: :model do
let(:current_user) { build_stubbed(:admin) }
let(:contract_instance) do
contract = instance_double(::OAuthClients::CreateContract, 'contract_instance')
allow(contract)
.to receive(:validate)
.and_return(contract_valid)
allow(contract)
.to receive(:errors)
.and_return(contract_errors)
contract
end
let(:contract_errors) { instance_double(ActiveModel::Errors, 'contract_errors') }
let(:contract_valid) { true }
let(:model_valid) { true }
let(:instance) do
described_class.new(user: current_user,
model: model_instance,
contract_class: contract_class,
contract_options: {})
end
let(:model_instance) { ::OAuthClient.new }
let(:contract_class) do
allow(::OAuthClients::CreateContract)
.to receive(:new)
.and_return(contract_instance)
::OAuthClients::CreateContract
end
let(:params) { {} }
before do
allow(model_instance)
.to receive(:valid?)
.and_return(model_valid)
end
subject { instance.call(params) }
it 'returns the instance as the result' do
expect(subject.result)
.to eql model_instance
end
it 'is a success' do
expect(subject)
.to be_success
end
context 'with params' do
let(:params) do
{
client_id: '0123456789-client_id',
client_secret: '1234567890-client_secret'
}
end
before do
subject
end
it 'assigns the params' do
expect(model_instance.client_id).to eq '0123456789-client_id'
expect(model_instance.client_secret).to eq '1234567890-client_secret'
end
end
context 'with an invalid contract' do
let(:contract_valid) { false }
it 'returns failure' do
expect(subject).not_to be_success
end
it "returns the contract's errors" do
expect(subject.errors)
.to eql(contract_errors)
end
end
end
Loading…
Cancel
Save