Browse Source

Fixes #3515 - Allow single tenant only application in microsoft

Boris Manojlovic 4 years ago
parent
commit
85d6e19892

+ 8 - 0
app/assets/javascripts/app/views/microsoft365/app_config.jst.eco

@@ -20,6 +20,14 @@
       <input id="client_secret" type="text" name="client_secret" value="<% if @external_credential && @external_credential.credentials: %><%= @external_credential.credentials.client_secret %><% end %>" class="form-control" required autocomplete="off" >
     </div>
   </div>
+  <div class="input form-group">
+    <div class="formGroup-label">
+      <label for="client_tenant">Tenant UUID/Name</label>
+    </div>
+    <div class="controls">
+      <input id="client_tenant" type="text" name="client_tenant" value="<%= @external_credential?.credentials?.client_tenant %>" class="form-control" required autocomplete="off" placeholder="common">
+    </div>
+  </div>
   <h2><%- @T('Your callback URL') %></h2>
   <div class="input form-group">
     <div class="controls">

+ 16 - 0
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

+ 7 - 0
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:       {},

+ 52 - 29
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.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',
+  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'),
     }
-    uri = URI::HTTPS.build(
+  end
+
+  def self.authorize_tokens_uri(tenant)
+    URI::HTTPS.build(
       host: 'login.microsoftonline.com',
-      path: '/common/oauth2/v2.0/token',
+      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 = 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?

+ 5 - 0
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

+ 25 - 0
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

+ 9 - 1
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