Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Azure single-tenant application support, using the Graph API #6728

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,20 @@ STRIPE_WEBHOOK_SECRET=
# Make sure to follow https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration on the cloud storage after setting this to true.
DIRECT_UPLOADS_ENABLED=

#MS OAUTH creds
# MS OAUTH creds
AZURE_APP_ID=
AZURE_APP_SECRET=

## MS Azure Tenant ID
# Set the following id to the id of your Azure 'tenant'.
# This will enable single tenant applications to work.
# If the following id is set, Chatwoot will use the Microsoft Graph API
# to send and receive emails, as that seems to be required for single
# tenant applications.
#
# https://learn.microsoft.com/en-us/azure/active-directory/fundamentals/active-directory-how-to-find-tenant
AZURE_TENANT_ID=

## Advanced configurations
## Change these values to fine tune performance
# control the concurrency setting of sidekiq
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts

def create
email = params[:authorization][:email]
redirect_url = microsoft_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
prompt: 'consent'
}
)
redirect_url = microsoft_client.auth_code.authorize_url(auth_params)
if redirect_url
email = email.downcase
::Redis::Alfred.setex(email, Current.account.id, 5.minutes)
Expand All @@ -25,4 +19,31 @@ def create
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end

# SMTP, Pop and IMAP are being deprecated by Outlook.
# https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/deprecation-of-basic-authentication-exchange-online
#
# As such, Microsoft has made it a real pain to use them.
# If AZURE_TENANT_ID is set, we will use the MS Graph API instead.
def auth_params
return graph_auth_params if ENV.fetch('AZURE_TENANT_ID', false)

standard_auth_params
end

def standard_auth_params
{
moxvallix marked this conversation as resolved.
Show resolved Hide resolved
redirect_uri: "#{base_url}/microsoft/callback",
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
prompt: 'consent'
}
end

def graph_auth_params
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: 'offline_access https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/Mail.Send openid profile',
prompt: 'consent'
}
end
end
8 changes: 6 additions & 2 deletions app/controllers/concerns/microsoft_concern.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ def microsoft_client
::OAuth2::Client.new(ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil),
{
site: 'https://login.microsoftonline.com',
authorize_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
token_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token'
authorize_url: "https://login.microsoftonline.com/#{azure_tenant_id}/oauth2/v2.0/authorize",
moxvallix marked this conversation as resolved.
Show resolved Hide resolved
token_url: "https://login.microsoftonline.com/#{azure_tenant_id}/oauth2/v2.0/token"
})
end

Expand All @@ -19,4 +19,8 @@ def parsed_body
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end

def azure_tenant_id
MicrosoftGraphAuth.azure_tenant_id
end
end
8 changes: 7 additions & 1 deletion app/jobs/inboxes/fetch_imap_email_inboxes_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob

def perform
Inbox.where(channel_type: 'Channel::Email').all.each do |inbox|
::Inboxes::FetchImapEmailsJob.perform_later(inbox.channel) if inbox.channel.imap_enabled
next unless inbox.channel.imap_enabled

if ENV.fetch('AZURE_TENANT_ID', false) && inbox.channel.microsoft?
moxvallix marked this conversation as resolved.
Show resolved Hide resolved
::Inboxes::FetchMsGraphEmailsJob.perform_later(inbox.channel)
else
::Inboxes::FetchImapEmailsJob.perform_later(inbox.channel)
end
end
end
end
101 changes: 101 additions & 0 deletions app/jobs/inboxes/fetch_ms_graph_emails_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
require 'net/http'

class Inboxes::FetchMsGraphEmailsJob < ApplicationJob
moxvallix marked this conversation as resolved.
Show resolved Hide resolved
queue_as :low

def perform(channel)
process_email_for_channel(channel)
rescue EOFError => e
Rails.logger.error e
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end

private

def should_fetch_email?(channel)
channel.imap_enabled? && channel.microsoft? && !channel.reauthorization_required?
end

def process_email_for_channel(channel)
# fetching email for microsoft provider
fetch_mail_for_channel(channel)

# clearing old failures like timeouts since the mail is now successfully processed
channel.reauthorized!
end

def fetch_mail_for_channel(channel)
return if channel.provider_config['access_token'].blank?

access_token = valid_access_token channel

return unless access_token

graph = graph_authenticate(access_token)

process_mails(graph, channel)
end

def process_mails(graph, channel)
response = graph.get_from_api('me/messages', {}, graph_query)

unless response.is_a?(Net::HTTPSuccess)
channel.authorization_error!
return false
end

json_response = JSON.parse(response.body)
json_response['value'].each do |message|
inbound_mail = Mail.read_from_string retrieve_mail_mime(graph, message['id'])

next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?

process_mail(inbound_mail, channel)
end
end

def retrieve_mail_mime(graph, id)
response = graph.get_from_api("me/messages/#{id}/$value")
return unless response.is_a?(Net::HTTPSuccess)

response.body
end

def graph_authenticate(access_token)
MicrosoftGraphApi.new(access_token)
end

def yesterday
(Time.zone.today - 1).strftime('%FT%TZ')
end

def tomorrow
(Time.zone.today + 1).strftime('%FT%TZ')
end

# Query to replicate the IMAP search used in Inboxes::FetchImapEmailsJob
# Selects the top 1000 records within the given filter, as that is the maximum
# page size for the API. Might need to look into paginating the requests later,
# for inboxes that receive more than 1000 emails a day?
#
# 1. https://learn.microsoft.com/en-us/graph/api/user-list-messages
# 2. https://learn.microsoft.com/en-us/graph/query-parameters
def graph_query
{
'$filter': "receivedDateTime ge #{yesterday} and receivedDateTime le #{tomorrow}",
'$top': '1000', '$select': 'id'
}
end

def process_mail(inbound_mail, channel)
Imap::ImapMailbox.new.process(inbound_mail, channel)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end

# Making sure the access token is valid for microsoft provider
def valid_access_token(channel)
Microsoft::RefreshOauthTokenService.new(channel: channel).access_token
end
end
10 changes: 10 additions & 0 deletions app/mailers/conversation_reply_mailer_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def prepare_mail(cc_bcc_enabled)

def ms_smtp_settings
return unless @inbox.email? && @channel.imap_enabled && @inbox.channel.provider == 'microsoft'
return ms_graph_settings if ENV.fetch('AZURE_TENANT_ID', false)

smtp_settings = {
address: 'smtp.office365.com',
Expand All @@ -40,6 +41,15 @@ def ms_smtp_settings
@options[:delivery_method_options] = smtp_settings
end

def ms_graph_settings
graph_settings = {
token: @channel.provider_config['access_token']
}

@options[:delivery_method] = :microsoft_graph
@options[:delivery_method_options] = graph_settings
end

def set_delivery_method
return unless @inbox.inbox_type == 'Email' && @channel.smtp_enabled

Expand Down
1 change: 1 addition & 0 deletions config/initializers/delivery_methods.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ActionMailer::Base.add_delivery_method :microsoft_graph, DeliveryMethods::MicrosoftGraph
26 changes: 26 additions & 0 deletions lib/delivery_methods/microsoft_graph.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Recently (around Feb/Mar 2023), Microsoft has made sending
# email through SMTP with Outlook near impossible, at least
# for single tenant applications.
#
# As such, adding a delivery method to use the Microsoft Graph
# API allows for emails to be sent again.
require 'base64'

class DeliveryMethods::MicrosoftGraph
def initialize(config)
@config = config
end

def deliver!(mail)
# Create a new API connection, and post the mail to the `me/sendMail` endpoint.
# https://learn.microsoft.com/en-us/graph/api/user-sendmail#example-4-send-a-new-message-using-mime-format

headers = {
'Content-Type' => 'text/plain'
}
body = Base64.encode64(mail.to_s)

graph = MicrosoftGraphApi.new(@config[:token])
graph.post_to_api('me/sendMail', headers, body)
end
end
62 changes: 62 additions & 0 deletions lib/microsoft_graph_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Simple HTTPS API helper class for interacting with MS Graph.
# Uses the standard ruby HTTP library for interacting with the API.

require 'uri'
require 'net/http'

class MicrosoftGraphApi
API_VERSION = 'v1.0'.freeze
API_PORT = 443
API_URL = "https://graph.microsoft.com/#{API_VERSION}".freeze

def initialize(token)
@token = token
end

# Simple get request to the endpoint
#
# 'queries' are the get variables after the main url
# eg. foo/bar?query=myquery
def get_from_api(endpoint, headers = {}, query = {})
uri = endpoint_to_uri(endpoint, query)
https = setup_https(uri.host)
request = Net::HTTP::Get.new(uri.request_uri)

# Assign each header to the request
headers.each { |key, value| request[key.to_s] = value.to_s }
request['Authorization'] = "Bearer #{@token}"

https.request(request)
end

# Simple post request to the endpoint
def post_to_api(endpoint, headers = {}, body = '')
uri = endpoint_to_uri(endpoint)
https = setup_https(uri.host)
request = Net::HTTP::Post.new(uri.path)

# Assign each header to the request
headers.each { |key, value| request[key.to_s] = value.to_s }
request['Authorization'] = "Bearer #{@token}"

request.body = body
https.request(request)
end

private

def setup_https(host)
https = Net::HTTP.new(host, API_PORT)
https.use_ssl = true
https
end

def endpoint_to_uri(endpoint, query = {})
endpoint.delete_prefix('/')
uri = URI("#{API_URL}/#{endpoint}")
return uri if query.empty?

uri.query = URI.encode_www_form(query)
uri
end
end
16 changes: 14 additions & 2 deletions lib/microsoft_graph_auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,27 @@
# Implements an OmniAuth strategy to get a Microsoft Graph
# compatible token from Azure AD
class MicrosoftGraphAuth < OmniAuth::Strategies::OAuth2
# Microsoft Azure Tenant
# For single tenant applications, meant to be used by
# organisations for their own apps, the 'common' endpoint is not allowed.
# If the environment variable 'AZURE_TENANT_ID' is set,
# this will return it's value, otherwise, it will default to 'common'.
#
# The tenant id for your Azure organization can be obtained by
# by accessing 'Tenant properties' from the Azure portal.
def self.azure_tenant_id
ENV.fetch('AZURE_TENANT_ID', 'common')
end

option :name, :microsoft_graph_auth

DEFAULT_SCOPE = 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send'

# Configure the Microsoft identity platform endpoints
option :client_options,
site: 'https://login.microsoftonline.com',
authorize_url: '/common/oauth2/v2.0/authorize',
token_url: '/common/oauth2/v2.0/token'
authorize_url: "/#{azure_tenant_id}/oauth2/v2.0/authorize",
token_url: "/#{azure_tenant_id}/oauth2/v2.0/token"

option :pcke, true
# Send the scope parameter during authorize
Expand Down