diff --git a/app/assets/javascripts/app/views/microsoft365/app_config.jst.eco b/app/assets/javascripts/app/views/microsoft365/app_config.jst.eco index fbf0ebe91..8bc671671 100644 --- a/app/assets/javascripts/app/views/microsoft365/app_config.jst.eco +++ b/app/assets/javascripts/app/views/microsoft365/app_config.jst.eco @@ -20,6 +20,14 @@ +
+
+ +
+
+ +
+

<%- @T('Your callback URL') %>

diff --git a/db/migrate/20210426184355_issue_3446_microsoft_365_tenants.rb b/db/migrate/20210426184355_issue_3446_microsoft_365_tenants.rb new file mode 100644 index 000000000..9bb17fedd --- /dev/null +++ b/db/migrate/20210426184355_issue_3446_microsoft_365_tenants.rb @@ -0,0 +1,16 @@ +class Issue3446Microsoft365Tenants < ActiveRecord::Migration[5.2] + def up + return if !Setting.exists?(name: 'system_init_done') + + setting = Setting.find_by name: 'auth_microsoft_office365_credentials' + setting.options[:form].push({ + display: 'App Tenant ID', + null: true, + name: 'app_tenant', + tag: 'input', + placeholder: 'common', + }) + + setting.save! + end +end diff --git a/db/seeds/settings.rb b/db/seeds/settings.rb index cc46e45bf..d5af58d19 100644 --- a/db/seeds/settings.rb +++ b/db/seeds/settings.rb @@ -1563,6 +1563,13 @@ Setting.create_if_not_exists( name: 'app_secret', tag: 'input', }, + { + display: 'App Tenant ID', + null: true, + name: 'app_tenant', + tag: 'input', + placeholder: 'common', + }, ], }, state: {}, diff --git a/lib/external_credential/microsoft365.rb b/lib/external_credential/microsoft365.rb index 869b52399..f95652d29 100644 --- a/lib/external_credential/microsoft365.rb +++ b/lib/external_credential/microsoft365.rb @@ -16,12 +16,16 @@ class ExternalCredential::Microsoft365 if credentials[:client_secret].blank? credentials[:client_secret] = external_credential.credentials['client_secret'] end + # client_tenant may be empty. Set only if key is nonexistant at all + if !credentials.key? :client_tenant + credentials[:client_tenant] = external_credential.credentials['client_tenant'] + end end raise Exceptions::UnprocessableEntity, 'No client_id param!' if credentials[:client_id].blank? raise Exceptions::UnprocessableEntity, 'No client_secret param!' if credentials[:client_secret].blank? - authorize_url = generate_authorize_url(credentials[:client_id]) + authorize_url = generate_authorize_url(credentials) { authorize_url: authorize_url, @@ -33,7 +37,7 @@ class ExternalCredential::Microsoft365 raise Exceptions::UnprocessableEntity, 'No Microsoft365 app configured!' if !external_credential raise Exceptions::UnprocessableEntity, 'No code for session found!' if !params[:code] - response = authorize_tokens(external_credential.credentials[:client_id], external_credential.credentials[:client_secret], params[:code]) + response = authorize_tokens(external_credential.credentials, params[:code]) %w[refresh_token access_token expires_in scope token_type id_token].each do |key| raise Exceptions::UnprocessableEntity, "No #{key} for authorization request found!" if response[key.to_sym].blank? end @@ -66,6 +70,7 @@ class ExternalCredential::Microsoft365 type: 'XOAUTH2', client_id: external_credential.credentials[:client_id], client_secret: external_credential.credentials[:client_secret], + client_tenant: external_credential.credentials[:client_tenant], ), } @@ -156,10 +161,9 @@ class ExternalCredential::Microsoft365 channel end - def self.generate_authorize_url(client_id, scope = 'https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access openid profile email') - + def self.generate_authorize_url(credentials, scope = 'https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access openid profile email') params = { - 'client_id' => client_id, + 'client_id' => credentials[:client_id], 'redirect_uri' => ExternalCredential.callback_url('microsoft365'), 'scope' => scope, 'response_type' => 'code', @@ -167,28 +171,20 @@ class ExternalCredential::Microsoft365 'prompt' => 'consent', } + tenant = credentials[:client_tenant].presence || 'common' + uri = URI::HTTPS.build( host: 'login.microsoftonline.com', - path: '/common/oauth2/v2.0/authorize', + path: "/#{tenant}/oauth2/v2.0/authorize", query: params.to_query ) uri.to_s end - def self.authorize_tokens(client_id, client_secret, authorization_code) - params = { - 'client_secret' => client_secret, - 'code' => authorization_code, - 'grant_type' => 'authorization_code', - 'client_id' => client_id, - 'redirect_uri' => ExternalCredential.callback_url('microsoft365'), - } - - uri = URI::HTTPS.build( - host: 'login.microsoftonline.com', - path: '/common/oauth2/v2.0/token', - ) + def self.authorize_tokens(credentials, authorization_code) + uri = authorize_tokens_uri(credentials[:client_tenant]) + params = authorize_tokens_params(credentials, authorization_code) response = Net::HTTP.post_form(uri, params) if response.code != 200 && response.body.blank? @@ -207,19 +203,28 @@ class ExternalCredential::Microsoft365 result.symbolize_keys end + def self.authorize_tokens_params(credentials, authorization_code) + { + 'client_secret' => credentials[:client_secret], + 'code' => authorization_code, + 'grant_type' => 'authorization_code', + 'client_id' => credentials[:client_id], + 'redirect_uri' => ExternalCredential.callback_url('microsoft365'), + } + end + + def self.authorize_tokens_uri(tenant) + URI::HTTPS.build( + host: 'login.microsoftonline.com', + path: "/#{tenant.presence || 'common'}/oauth2/v2.0/token", + ) + end + def self.refresh_token(token) return token if token[:created_at] >= Time.zone.now - 50.minutes - params = { - 'client_id' => token[:client_id], - 'client_secret' => token[:client_secret], - 'refresh_token' => token[:refresh_token], - 'grant_type' => 'refresh_token', - } - uri = URI::HTTPS.build( - host: 'login.microsoftonline.com', - path: '/common/oauth2/v2.0/token', - ) + params = refresh_token_params(token) + uri = refresh_token_uri(token) response = Net::HTTP.post_form(uri, params) if response.code != 200 && response.body.blank? @@ -238,6 +243,24 @@ class ExternalCredential::Microsoft365 ) end + def self.refresh_token_params(credentials) + { + 'client_id' => credentials[:client_id], + 'client_secret' => credentials[:client_secret], + 'refresh_credentials' => credentials[:refresh_credentials], + 'grant_type' => 'refresh_credentials', + } + end + + def self.refresh_token_uri(credentials) + tenant = credentials[:client_tenant].presence || 'common' + + URI::HTTPS.build( + host: 'login.microsoftonline.com', + path: "/#{tenant}/oauth2/v2.0/token", + ) + end + def self.user_info(id_token) split = id_token.split('.')[1] return if split.blank? diff --git a/lib/omniauth/microsoft_office365_database.rb b/lib/omniauth/microsoft_office365_database.rb index 5d51c084a..e3ec00a21 100644 --- a/lib/omniauth/microsoft_office365_database.rb +++ b/lib/omniauth/microsoft_office365_database.rb @@ -7,7 +7,12 @@ class MicrosoftOffice365Database < OmniAuth::Strategies::MicrosoftOffice365 config = Setting.get('auth_microsoft_office365_credentials') || {} args[0] = config['app_id'] args[1] = config['app_secret'] + tenant = config['app_tenant'].presence || 'common' + super + + @options[:client_options][:authorize_url] = "/#{tenant}/oauth2/v2.0/authorize" + @options[:client_options][:token_url] = "/#{tenant}/oauth2/v2.0/token" end end diff --git a/spec/db/migrate/issue_3446_microsoft_365_tenants_spec.rb b/spec/db/migrate/issue_3446_microsoft_365_tenants_spec.rb new file mode 100644 index 000000000..ee5a609a5 --- /dev/null +++ b/spec/db/migrate/issue_3446_microsoft_365_tenants_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +RSpec.describe Issue3446Microsoft365Tenants, type: :db_migration do + context 'when having pre-tenant setting' do + before do + setting.options['form'] = setting.options['form'].slice 0, 2 + setting.save! + end + + let(:setting) { Setting.find_by(name: 'auth_microsoft_office365_credentials') } + + it 'adds tenant field to form options' do + expect { migrate } + .to change { setting.reload.options['form'].last['name'] } + .to('app_tenant') + end + + it 'changes form fields count from 2 to 3 ' do + expect { migrate } + .to change { setting.reload.options['form'].count } + .from(2) + .to(3) + end + end +end diff --git a/spec/lib/external_credential/microsoft365_spec.rb b/spec/lib/external_credential/microsoft365_spec.rb index afd49f1bc..4c41e2bd4 100644 --- a/spec/lib/external_credential/microsoft365_spec.rb +++ b/spec/lib/external_credential/microsoft365_spec.rb @@ -3,7 +3,9 @@ require 'rails_helper' RSpec.describe ExternalCredential::Microsoft365 do let(:token_url) { 'https://login.microsoftonline.com/common/oauth2/v2.0/token' } + let(:token_url_with_tenant) { 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token' } let(:authorize_url) { "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?access_type=offline&client_id=#{client_id}&prompt=consent&redirect_uri=http%3A%2F%2Fzammad.example.com%2Fapi%2Fv1%2Fexternal_credentials%2Fmicrosoft365%2Fcallback&response_type=code&scope=https%3A%2F%2Foutlook.office.com%2FIMAP.AccessAsUser.All+https%3A%2F%2Foutlook.office.com%2FSMTP.Send+offline_access+openid+profile+email" } + let(:authorize_url_with_tenant) { "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?access_type=offline&client_id=#{client_id}&prompt=consent&redirect_uri=http%3A%2F%2Fzammad.example.com%2Fapi%2Fv1%2Fexternal_credentials%2Fmicrosoft365%2Fcallback&response_type=code&scope=https%3A%2F%2Foutlook.office.com%2FIMAP.AccessAsUser.All+https%3A%2F%2Foutlook.office.com%2FSMTP.Send+offline_access+openid+profile+email" } let(:id_token) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtnMkxZczJUMENUaklmajRydDZKSXluZW4zOCJ9.eyJhdWQiOiIyMTk4NTFhYS0wMDAwLTRhNDctMTExMS0zMmQwNzAyZTAxMjM0IiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5taWNyb3NvZnRvbmxpbmUuY29tLzM2YTlhYjU1LWZpZmEtMjAyMC04YTc4LTkwcnM0NTRkYmNmZDJkL3YyLjAiLCJpYXQiOjEzMDE1NTE4MzUsIm5iZiI6MTMwMTU1MTgzNSwiZXhwIjoxNjAxNTU5NzQ0LCJuYW1lIjoiRXhhbXBsZSBVc2VyIiwib2lkIjoiMTExYWIyMTQtMTJzNy00M2NnLThiMTItM2ozM2UydDBjYXUyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoidGVzdEBleGFtcGxlLmNvbSIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInJoIjoiMC40MjM0LWZmZnNmZGdkaGRLZUpEU1hiejlMYXBSbUNHZGdmZ2RmZ0kwZHkwSEF1QlhaSEFNYy4iLCJzdWIiOiJYY0VlcmVyQkVnX0EzNWJlc2ZkczNMTElXNjU1NFQtUy0ycGRnZ2R1Z3c1NDNXT2xJIiwidGlkIjoiMzZhOWFiNTUtZmlmYS0yMDIwLThhNzgtOTByczQ1NGRiY2ZkMmQiLCJ1dGkiOiJEU0dGZ3Nhc2RkZmdqdGpyMzV3cWVlIiwidmVyIjoiMi4wIn0=.l0nglq4rIlkR29DFK3PQFQTjE-VeHdgLmcnXwGvT8Z-QBaQjeTAcoMrVpr0WdL6SRYiyn2YuqPnxey6N0IQdlmvTMBv0X_dng_y4CiQ8ABdZrQK0VSRWZViboJgW5iBvJYFcMmVoilHChueCzTBnS1Wp2KhirS2ymUkPHS6AB98K0tzOEYciR2eJsJ2JOdo-82oOW4w6tbbqMvzT3DzsxqPQRGe2hUbNqo6gcwJLqq4t0bNf5XiYThw1sv4IivERmqW_pfybXEseKyZGd4NnJ6WwwOgTz5tkoLwls_YeDZVcp_Fpw9XR7J0UlyPqLtoUEjVihdyrJjAbdtHFKdOjrw' } let(:access_token) { '000.0000lvC3gAbjs8CYoKitfqM5LBS5N13374MCg6pNpZ28mxO2HuZvg0000_rsW00aACmFEto1BJeGDuu0000vmV6Esqv78iec-FbEe842ZevQtOOemQyQXjhMs62K1E6g3ehDLPRp6j4vtpSKSb6I-3MuDPfdzdqI23hM0' } @@ -15,6 +17,7 @@ RSpec.describe ExternalCredential::Microsoft365 do let(:client_id) { '123' } let(:client_secret) { '345' } + let(:client_tenant) { 'tenant' } let(:authorization_code) { '567' } let(:email_address) { 'test@example.com' } @@ -329,9 +332,14 @@ RSpec.describe ExternalCredential::Microsoft365 do describe '.generate_authorize_url' do it 'generates valid URL' do - url = described_class.generate_authorize_url(client_id) + url = described_class.generate_authorize_url(client_id: client_id) expect(url).to eq(authorize_url) end + + it 'generates valid URL with tenant' do + url = described_class.generate_authorize_url(client_id: client_id, client_tenant: 'tenant') + expect(url).to eq(authorize_url_with_tenant) + end end describe '.user_info' do