parent
212b837ee1
commit
fcf3f34899
@ -0,0 +1,144 @@ |
||||
#-- 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 OAuth2 Authorization Code Grant redirects from a Authorization Server to |
||||
# "callback" endpoint. |
||||
class OAuthClientsController < ApplicationController |
||||
before_action :find_oauth_client |
||||
before_action :set_state |
||||
before_action :set_code |
||||
|
||||
# Provide the OAuth2 "callback" endpoint. |
||||
# The Authorization Server redirects |
||||
# here after successful authentication and authorization. |
||||
# This endpoint gets a "code" parameter that cryptographically |
||||
# contains a grant. |
||||
# We get here by a URL like this: |
||||
# http://localhost:4200/oauth_clients/asdf12341234qsdfasdfasdf/callback? |
||||
# state=http%3A%2F%2Flocalhost%3A4200%2Fprojects%2Fdemo-project%2Foauth2_example& |
||||
# code=MQoOnUTJGFdAo5jBGD1SqnDH0PV6yioG7NoYM2zZZlK3g6LuKrGUmOxjIS1bIy7fHEfZy2WrgYcx |
||||
def callback |
||||
connection_manager = OAuthClients::ConnectionManager.new(user: User.current, oauth_client: @oauth_client) |
||||
|
||||
# Exchange the code with a token using a HTTP call to the Authorization Server |
||||
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_redirect_uri(@state) |
||||
redirect_to redirect_uri |
||||
else |
||||
# We got a list of errors from ::OAuthClients::ConnectionManager |
||||
set_oauth_errors(service_result) |
||||
|
||||
redirect_user_or_admin(@state) do |
||||
# If the current user is an admin, we send her directly to the |
||||
# settings that she needs to edit. |
||||
redirect_to admin_settings_storage_path(@oauth_client.integration) |
||||
end |
||||
end |
||||
end |
||||
|
||||
private |
||||
|
||||
def set_oauth_errors(service_result) |
||||
flash[:error] = ["#{t(:'oauth_client.errors.oauth_authorization_code_grant_had_errors')}:"] |
||||
service_result.errors.each do |error| |
||||
flash[:error] << "#{t(:'oauth_client.errors.oauth_reported')}: #{error.full_message}" |
||||
end |
||||
end |
||||
|
||||
def set_code |
||||
# The OAuth2 provider should have sent a code when using response_type = "code" |
||||
# So this could either be an error from the Authorization Server (i.e. Nextcloud) or |
||||
# ::OAuthClient::ConnectionManager has used the wrong response_type. |
||||
@code = params[:code] |
||||
if @code.blank? |
||||
flash[:error] = [I18n.t('oauth_client.errors.oauth_code_not_present'), |
||||
I18n.t('oauth_client.errors.oauth_code_not_present_explanation')] |
||||
|
||||
redirect_user_or_admin params[:state] do |
||||
# If the current user is an admin, we send her directly to the |
||||
# settings that she needs to edit. |
||||
redirect_to admin_settings_storage_path(@oauth_client.integration) |
||||
end |
||||
end |
||||
end |
||||
|
||||
def set_state |
||||
# state is used by OpenProject to contain the redirection URL where to |
||||
# continue after receiving an OAuth2 access 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_user_or_admin(nil) do |
||||
# If the current user is an admin, we send her directly to the |
||||
# settings that she needs to edit. |
||||
redirect_to admin_settings_storage_path(@oauth_client.integration) |
||||
end |
||||
end |
||||
end |
||||
|
||||
def find_oauth_client |
||||
@oauth_client = OAuthClient.find_by(client_id: params[:oauth_client_id]) |
||||
if @oauth_client.nil? |
||||
# 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 the Authorization Server (i.e. Nextcloud) after updating the OpenProject |
||||
# side with a new client_id and client_secret. |
||||
flash[:error] = [I18n.t('oauth_client.errors.oauth_client_not_found'), |
||||
I18n.t('oauth_client.errors.oauth_client_not_found_explanation')] |
||||
|
||||
redirect_user_or_admin params[:state] do |
||||
# Something must be wrong in the storage's setup |
||||
redirect_to admin_settings_storages_path |
||||
end |
||||
end |
||||
end |
||||
|
||||
def redirect_user_or_admin(state = nil) |
||||
# This needs to be modified as soon as we support more integration types. |
||||
if User.current.admin && state && nextcloud? |
||||
yield |
||||
elsif state |
||||
flash[:error] = [t(:'oauth_client.errors.oauth_issue_contact_admin')] |
||||
redirect_to state |
||||
else |
||||
redirect_to home_path |
||||
end |
||||
end |
||||
|
||||
def nextcloud? |
||||
@oauth_client&.integration && \ |
||||
@oauth_client.integration.is_a?(::Storages::Storage) && \ |
||||
@oauth_client.integration.provider_type == 'nextcloud' |
||||
end |
||||
end |
@ -0,0 +1,41 @@ |
||||
#-- 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, optional: false |
||||
belongs_to :oauth_client, optional: false |
||||
|
||||
validates :user, uniqueness: { scope: :oauth_client } |
||||
|
||||
validates :access_token, length: { minimum: 1, maximum: 255 } |
||||
validates :refresh_token, length: { minimum: 1, maximum: 255 } |
||||
end |
@ -0,0 +1,214 @@ |
||||
#-- 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, :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 Authorization Server 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 Authorization Server. |
||||
# @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_redirect_uri(state) |
||||
# In the current implementation "state" just consists of the URL of |
||||
# the initial page, possibly with "&var=value" added parameters. |
||||
# So we can just return this URI. |
||||
state |
||||
end |
||||
|
||||
# Called by callback_page with a cryptographic "code" that indicates |
||||
# that the user has successfully authorized the OAuth2 Authorization Server. |
||||
# 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 Authorization Server |
||||
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 |
||||
) |
||||
end |
||||
|
||||
# Update an OpenProject token based on updated values from a |
||||
# Rack::OAuth2::AccessToken::Bearer after a OAuth2 refresh operation |
||||
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,18 @@ |
||||
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 :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,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 invalid 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,163 @@ |
||||
#-- 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 'rack/test' |
||||
|
||||
describe 'OAuthClient callback endpoint', :enable_storages, type: :request do |
||||
include Rack::Test::Methods |
||||
include API::V3::Utilities::PathHelper |
||||
|
||||
let(:current_user) { create(:user) } |
||||
|
||||
let(:code) do |
||||
"mBf4v9hNA6hXXCWHd5mZggsAa2FSOXinx9jKx1yjSoDwOPOX4k6zGEgM2radqgg1nRwXCqvIe5xZsfwqMIaTdL" + |
||||
"jYnl0OpYOc6ePblzQTmnlp7RYiHW09assYEJjv9zps" |
||||
end |
||||
let(:state) { "https://example.org/my-path?and=some&query=params" } |
||||
let(:oauth_client_token) { create :oauth_client_token } |
||||
let(:oauth_client) do |
||||
create :oauth_client, |
||||
client_id: 'kETWr2XsjPxhVbN7Q5jmPq83xribuUTRzgfXthpYT0vSqyJWm4dOnivKzHiZasf0', |
||||
client_secret: 'J1sg4L5PYbM2RZL3pUyxTnamvfpcP5eUcCPmeCQHJO60Gy6CJIdDaF4yXOeC8BPS' |
||||
end |
||||
let(:rack_oauth2_client) do |
||||
instance_double(::Rack::OAuth2::Client) |
||||
end |
||||
let(:connection_manager) do |
||||
instance_double(::OAuthClients::ConnectionManager) |
||||
end |
||||
let(:uri) { URI(File.join('oauth_clients', oauth_client.client_id, 'callback')) } |
||||
|
||||
subject(:response) { last_response } |
||||
|
||||
before do |
||||
host! 'https://my-example.org' |
||||
login_as current_user |
||||
|
||||
allow(::Rack::OAuth2::Client).to receive(:new).and_return(rack_oauth2_client) |
||||
allow(rack_oauth2_client) |
||||
.to receive(:access_token!).with(:body) |
||||
.and_return( |
||||
::Rack::OAuth2::AccessToken::Bearer.new(access_token: 'xyzaccesstoken', |
||||
refresh_token: 'xyzrefreshtoken') |
||||
) |
||||
allow(rack_oauth2_client).to receive(:authorization_code=) |
||||
end |
||||
|
||||
shared_examples 'with errors and state param, not being admin' do |
||||
it 'redirects to URI encode in state' do |
||||
expect(response.status).to eq 302 |
||||
expect(response.location).to eq state |
||||
end |
||||
end |
||||
|
||||
shared_examples 'with errors, being an admin' do |
||||
it 'redirects to admin settings for the storage' do |
||||
expect(response.status).to eq 302 |
||||
expect(URI(response.location).path).to eq admin_settings_storage_path(oauth_client.integration) |
||||
end |
||||
end |
||||
|
||||
context 'with valid params' do |
||||
context 'without errors' do |
||||
before do |
||||
uri.query = URI.encode_www_form([['code', code], ['state', state]]) |
||||
get uri.to_s |
||||
|
||||
subject |
||||
end |
||||
|
||||
it 'redirects to the URL that was provided by the state param' do |
||||
expect(rack_oauth2_client).to have_received(:authorization_code=).with(code) |
||||
expect(response.status).to eq 302 |
||||
expect(response.location).to eq state |
||||
expect(::OAuthClientToken.count).to eq 1 |
||||
expect(::OAuthClientToken.last.access_token).to eq 'xyzaccesstoken' |
||||
expect(::OAuthClientToken.last.refresh_token).to eq 'xyzrefreshtoken' |
||||
end |
||||
end |
||||
|
||||
context 'with some other error, having a state param' do |
||||
before do |
||||
allow(::OAuthClients::ConnectionManager) |
||||
.to receive(:new).and_return(connection_manager) |
||||
allow(connection_manager) |
||||
.to receive(:code_to_token).with(code).and_return(ServiceResult.new(success: false)) |
||||
|
||||
uri.query = URI.encode_www_form([['code', code], ['state', state]]) |
||||
get uri.to_s |
||||
|
||||
subject |
||||
end |
||||
|
||||
context 'with current_user being an admin' do |
||||
let(:current_user) { create :admin } |
||||
|
||||
it_behaves_like 'with errors, being an admin' |
||||
end |
||||
|
||||
context 'with current_user not being an admin' do |
||||
it_behaves_like 'with errors and state param, not being admin' |
||||
end |
||||
end |
||||
end |
||||
|
||||
context 'without code param, but with state param,' do |
||||
before do |
||||
uri.query = URI.encode_www_form([['state', state]]) |
||||
get uri.to_s |
||||
|
||||
subject |
||||
end |
||||
|
||||
context 'with current_user being not being an admin' do |
||||
it_behaves_like 'with errors and state param, not being admin' |
||||
end |
||||
|
||||
context 'with current_user being an admin' do |
||||
let(:current_user) { create :admin } |
||||
|
||||
it_behaves_like 'with errors, being an admin' |
||||
end |
||||
end |
||||
|
||||
context 'without state param' do |
||||
before do |
||||
uri.query = URI.encode_www_form([['code', code]]) |
||||
get uri.to_s |
||||
|
||||
subject |
||||
end |
||||
|
||||
it 'redirects to home' do |
||||
expect(response.status).to eq 302 |
||||
expect(URI(response.location).path).to eq home_path |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,334 @@ |
||||
#-- 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) { "http://example.org" } |
||||
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 Authorization Server (Nextcloud) redirects |
||||
# to a "callback" endpoint on the OAuth2 client (OpenProject): |
||||
# 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 |
Loading…
Reference in new issue