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

Feat: Support for Microsoft Oauth in Email Channel #6227

Merged
merged 51 commits into from
Jan 16, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
f3c4fcf
fix:
tejaswinichile Dec 30, 2022
cab4aa3
fix:
tejaswinichile Dec 30, 2022
579bebc
refresh tokens and fetch the mails from ms graph
tejaswinichile Jan 3, 2023
bb5a248
fix: refresh tokens with IMAP; commented conde for the HTTParty
tejaswinichile Jan 4, 2023
3247067
refresh tokens and fetch the mails from ms graph
tejaswinichile Jan 3, 2023
d91d1dc
code cleaning, hash indifferent access fix, updated schedule class an…
tejaswinichile Jan 4, 2023
44a55ac
send email via SMTP
tejaswinichile Jan 5, 2023
c71c602
code sanity changes, addded comments and references
tejaswinichile Jan 5, 2023
7a58a70
rubocop and spec fixes
tejaswinichile Jan 6, 2023
330a36e
Feat: microsoft email channel oauth provider and fetching emails
tejaswinichile Jan 10, 2023
96368e8
minor fixes
tejaswinichile Jan 10, 2023
5f99ffa
feat: code clean up and refactoring
tejaswinichile Jan 11, 2023
48bf5b5
feat: code clean up and refactoring
tejaswinichile Jan 11, 2023
b10ccb6
Remove all the frontend code
pranavrajs Jan 12, 2023
90471d7
Merge branch 'develop' into feat/microsoft-oauth-apis
pranavrajs Jan 12, 2023
7f43ab5
feat: MS reauthorization
tejaswinichile Jan 12, 2023
cb701d6
Merge branch 'feat/microsoft-oauth-apis' of https://github.com/chatwo…
tejaswinichile Jan 12, 2023
3fe0d06
update microsoft oauth concern
tejaswinichile Jan 12, 2023
cd9e93d
PR comments changes
tejaswinichile Jan 12, 2023
d8acd5f
PR comments changes
tejaswinichile Jan 12, 2023
3c23975
Fix: Specs
tejaswinichile Jan 12, 2023
b10d0f3
Merge branch 'feat/microsoft-oauth-apis' of https://github.com/chatwo…
tejaswinichile Jan 12, 2023
8deac0f
fix: rubocop
tejaswinichile Jan 12, 2023
de416ad
fix: rubocop
tejaswinichile Jan 12, 2023
85abe97
Merge branch 'feat/microsoft-oauth-apis' of https://github.com/chatwo…
tejaswinichile Jan 12, 2023
7cc3113
Merge branch 'develop' into feat/microsoft-oauth-apis
tejaswinichile Jan 13, 2023
7521813
fix: remove comman key for redis account id setup
tejaswinichile Jan 13, 2023
d55ff39
fix: remove comman key for redis account id setup
tejaswinichile Jan 13, 2023
179fb37
fix: remove comman key for redis account id setup
tejaswinichile Jan 13, 2023
8c668f6
Merge branch 'feat/microsoft-oauth-apis' of https://github.com/chatwo…
tejaswinichile Jan 13, 2023
d5a8e05
fix: PR comments resolved
tejaswinichile Jan 13, 2023
f0bcdda
fix: moved refersh_ms_oauth_token job to service
tejaswinichile Jan 13, 2023
4b28672
chore: refactor the token service
sojan-official Jan 13, 2023
d53b077
chore: rubocop fixes
sojan-official Jan 13, 2023
2708fc2
spces for callbacks controlelr
tejaswinichile Jan 13, 2023
5d712dd
spces for callbacks controlelr
tejaswinichile Jan 13, 2023
526b83a
Merge branch 'feat/microsoft-oauth-apis' of https://github.com/chatwo…
tejaswinichile Jan 13, 2023
9d941de
Merge branch 'develop' into feat/microsoft-oauth-apis
tejaswinichile Jan 13, 2023
799f46b
fix: failing specs
tejaswinichile Jan 16, 2023
5eab1de
Merge branch 'develop' into feat/microsoft-oauth-apis
tejaswinichile Jan 16, 2023
f18d7a5
chore: remove unneccesary mocks
sojan-official Jan 16, 2023
8e55349
Merge branch 'develop' into feat/microsoft-oauth-apis
sojan-official Jan 16, 2023
908c409
chore: callback controller specs
sojan-official Jan 16, 2023
6a9f78c
chore: remove unwanted code
sojan-official Jan 16, 2023
6adebd1
chore: spec for model
sojan-official Jan 16, 2023
80d977b
chore: rubocop
sojan-official Jan 16, 2023
7da09f6
chore: more specs
sojan-official Jan 16, 2023
cae923b
chore: refactor
sojan-official Jan 16, 2023
ba09939
chore: refactor
sojan-official Jan 16, 2023
10cf72c
chore: fix specs
sojan-official Jan 16, 2023
759fecf
Merge branch 'develop' into feat/microsoft-oauth-apis
sojan-official Jan 16, 2023
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
9 changes: 7 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ REDIS_SENTINELS=
REDIS_SENTINEL_MASTER_NAME=

# By default Chatwoot will pass REDIS_PASSWORD as the password value for sentinels
# Use the following environment variable to customize passwords for sentinels.
# Use the following environment variable to customize passwords for sentinels.
# Use empty string if sentinels are configured with out passwords
# REDIS_SENTINEL_PASSWORD=

Expand All @@ -45,7 +45,7 @@ REDIS_SENTINEL_MASTER_NAME=
# REDIS_OPENSSL_VERIFY_MODE=none

# Postgres Database config variables
# You can leave POSTGRES_DATABASE blank. The default name of
# You can leave POSTGRES_DATABASE blank. The default name of
# the database in the production environment is chatwoot_production
# POSTGRES_DATABASE=
POSTGRES_HOST=postgres
Expand Down Expand Up @@ -213,3 +213,8 @@ STRIPE_WEBHOOK_SECRET=
# Set to true if you want to upload files to cloud storage using the signed url
# 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
AZURE_APP_ID=
AZURE_APP_SECRET=
AZURE_SCOPES = 'Mail.Send offline_access https://graph.microsoft.com/IMAP.AccessAsUser.All https://graph.microsoft.com/SMTP.Send'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tejaswinichile Why do we need to take this as an env variable?

4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ gem 'json_schemer'
gem 'rack-attack'
# a utility tool for streaming, flexible and safe downloading of remote files
gem 'down', '~> 5.0'
# authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth'

##-- for active storage --##
gem 'aws-sdk-s3', require: false
Expand Down Expand Up @@ -186,3 +188,5 @@ group :development, :test do
gem 'spring'
gem 'spring-watcher-listen'
end
# worked with microsoft refresh token
gem 'omniauth-oauth2'
24 changes: 24 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,8 @@ GEM
gli (2.21.0)
globalid (1.0.0)
activesupport (>= 5.0)
gmail_xoauth (0.4.2)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tejaswinichile net::imap latest versions support xoauth automatically. We can upgrade the package. No need to do it in this PR. We can take it as a separate task.

oauth (>= 0.3.6)
google-apis-core (0.7.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
Expand Down Expand Up @@ -437,6 +439,20 @@ GEM
nokogiri (1.13.10-x86_64-linux)
racc (~> 1.4)
oauth (0.5.10)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
omniauth (2.1.0)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
orm_adapter (0.5.0)
os (1.1.4)
parallel (1.22.1)
Expand Down Expand Up @@ -465,6 +481,8 @@ GEM
rack (>= 1.0, < 3)
rack-cors (1.1.1)
rack (>= 2.0.0)
rack-protection (3.0.5)
rack
rack-proxy (0.7.2)
rack
rack-test (2.0.2)
Expand Down Expand Up @@ -620,6 +638,9 @@ GEM
gli
hashie
websocket-driver
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
spring (2.1.1)
spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0)
Expand Down Expand Up @@ -663,6 +684,7 @@ GEM
valid_email2 (4.0.3)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.1)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.0)
Expand Down Expand Up @@ -735,6 +757,7 @@ DEPENDENCIES
flag_shih_tzu
foreman
geocoder
gmail_xoauth
google-cloud-dialogflow
google-cloud-storage
groupdate
Expand All @@ -756,6 +779,7 @@ DEPENDENCIES
maxminddb
mock_redis
newrelic_rpm
omniauth-oauth2
pg
pg_search
procore-sift
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :check_authorization

def create
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'
})
redirect_url = 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',
prompt: 'consent'
})
if redirect_url
::Redis::Alfred.setex('current_account_id', Current.account.id)
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end

private

def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end

def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
38 changes: 38 additions & 0 deletions app/controllers/concerns/microsoft_concern.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
module MicrosoftConcern
extend ActiveSupport::Concern

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'
}
)
end

private

def parsed_body
@parsed_body ||= Rack::Utils.parse_nested_query(@response.raw_response.body)
end

def host
ENV.fetch('FRONTEND_URL', '')
end

def twitter_client
Twitty::Facade.new do |config|
config.consumer_key = ENV.fetch('TWITTER_CONSUMER_KEY', nil)
config.consumer_secret = ENV.fetch('TWITTER_CONSUMER_SECRET', nil)
config.base_url = twitter_api_base_url
config.environment = ENV.fetch('TWITTER_ENVIRONMENT', '')
end
end

def twitter_api_base_url
'https://api.twitter.com'
end
end
73 changes: 73 additions & 0 deletions app/controllers/microsoft/callbacks_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
class Microsoft::CallbacksController < ApplicationController
include MicrosoftConcern

def show
@response = microsoft_client.auth_code.get_token(
oauth_code,
redirect_uri: "#{base_utl}/microsoft/callback"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
redirect_uri: "#{base_utl}/microsoft/callback"
redirect_uri: "#{base_url}/microsoft/callback"

)

ActiveRecord::Base.transaction do
inbox = find_or_create_inbox
::Redis::Alfred.delete('current_account_id')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's not use current_account_id as the Redis key here.

redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let us rename this path.

rescue StandardError => e
Rails.logger.error e
redirect_to microsoft_app_redirect_url
end
end

private

def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end

def oauth_code
params[:code]
end

def users_data
decoded_token = JWT.decode parsed_body[:id_token], nil, false
decoded_token[0]
end

def parsed_body
@parsed_body ||= @response.response.parsed
end

def account_id
::Redis::Alfred.get('current_account_id')
end

def account
@account ||= Account.find(account_id)
end

def microsoft_app_redirect_url
app_new_microsoft_inbox_url(account_id: account.id)
end

def find_or_create_inbox
channel_email = create_imap_email_channel(users_data)

account.inboxes.create!(
account: account,
name: users_data['name'],
channel: channel_email
)
end

def create_imap_email_channel
channel_email = Channel::Email.find_or_initialize_by(email: users_data['email'], account: account)
channel_email.imap_login = users_data['email']
channel_email.imap_address = 'outlook.office365.com'
channel_email.imap_port = '993'
channel_email.imap_enabled = true,
channel_email.provider = 'microsoft'
channel_email.provider_config = { access_token: parsed_body['access_token'], refresh_token: parsed_body['refresh_token'],
expires_on: (Time.current.utc + 1.hour).to_s }
channel_email.save!
channel_email
end
end
2 changes: 2 additions & 0 deletions app/helpers/api/v1/accounts/microsoft/authorization_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module Api::V1::Accounts::Microsoft::AuthorizationHelper
end
42 changes: 42 additions & 0 deletions app/helpers/api/v1/microsoft_graph_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
require 'httparty'

# This will roll out smtp mail sender in the future
# No use as of now
module Api::V1::MicrosoftGraphHelper
GRAPH_HOST = 'https://graph.microsoft.com'.freeze

def make_api_call(method, endpoint, token, payload = nil)
headervals ||= {}
headervals[:Authorization] = "Bearer #{token}"
headervals[:Accept] = 'application/json'

params ||= {}

case method.upcase
when 'GET'
HTTParty.get "#{GRAPH_HOST}#{endpoint}",
headers: headervals,
query: params
when 'POST'
headervals['Content-Type'] = 'application/json'
HTTParty.post "#{GRAPH_HOST}#{endpoint}",
headers: headervals,
query: params,
body: payload&.to_json
else
raise "HTTP method #{method.upcase} not implemented"
end
end

def send_reply_to_mail(token, _timezone, _subject, body)
create_message_reply_url = '/v1.0/me/sendMail'

mail_body = body

make_api_call 'POST', create_message_reply_url, token, mail_body
end

def user_info(access_token)
make_api_call 'GET', '/v1.0/me/', access_token
end
end
62 changes: 62 additions & 0 deletions app/jobs/channels/refresh_ms_oauth_token_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# refer: https://gitlab.com/gitlab-org/ruby/gems/gitlab-mail_room/-/blob/master/lib/mail_room/microsoft_graph/connection.rb
# refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp/tree/b4a6869fe4a438cde42b161196484a929f1bee46
# https://learn.microsoft.com/en-us/azure/active-directory/develop/active-directory-configurable-token-lifetimes

require 'microsoft_graph_auth'

class Channels::RefreshMsOauthTokenJob < ApplicationJob
queue_as :low

def perform
Channel::Email.where(provider: 'microsoft').each do |channel|
# refresh the token here, with microsoft offline_access scope
provider_config = channel.provider_config || {}

refresh_tokens(channel, provider_config.with_indifferent_access) if provider_config[:refresh_token].present?
end
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's move this to services instead. since we won't be using the perform block nor need to inherit the application job. We only need the access_token method, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right! makes sense 👍🏼


# if the token is not expired yet then skip the refresh token step
def access_token(channel, provider_config)
if Time.current.utc >= expires_on(provider_config['expires_on'])
# Token expired, refresh
new_hash = refresh_tokens channel, provider_config
new_hash[:access_token]
else
provider_config[:access_token]
end
end

def expires_on(expiry)
expiry.presence ? DateTime.parse(expiry) - 5.minutes : Time.current.utc
end

# <RefreshTokensSnippet>
def refresh_tokens(channel, token_hash)
oauth_strategy = ::MicrosoftGraphAuth.new(
nil, ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil)
)

token = OAuth2::AccessToken.new(
oauth_strategy.client, token_hash['access_token'],
refresh_token: token_hash['refresh_token']
)

# Refresh the tokens
new_tokens = token.refresh!.to_hash.slice(:access_token, :refresh_token, :expires_in)

update_channel_provider_config(channel, new_tokens)
channel.provider_config
end
# </RefreshTokensSnippet>

def update_channel_provider_config(channel, new_tokens)
new_tokens = new_tokens.with_indifferent_access
channel.provider_config = {
access_token: new_tokens.delete(:access_token),
refresh_token: new_tokens.delete(:refresh_token),
expires_on: new_tokens.delete(:expires_in)
}
channel.save!
end
end
Loading