-
-
Notifications
You must be signed in to change notification settings - Fork 3.7k
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
Changes from 13 commits
f3c4fcf
cab4aa3
579bebc
bb5a248
3247067
d91d1dc
44a55ac
c71c602
7a58a70
330a36e
96368e8
5f99ffa
48bf5b5
b10ccb6
90471d7
7f43ab5
cb701d6
3fe0d06
cd9e93d
d8acd5f
3c23975
b10d0f3
8deac0f
de416ad
85abe97
7cc3113
7521813
d55ff39
179fb37
8c668f6
d5a8e05
f0bcdda
4b28672
d53b077
2708fc2
5d712dd
526b83a
9d941de
799f46b
5eab1de
f18d7a5
8e55349
908c409
6a9f78c
6adebd1
80d977b
7da09f6
cae923b
ba09939
10cf72c
759fecf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -249,6 +249,8 @@ GEM | |
gli (2.21.0) | ||
globalid (1.0.0) | ||
activesupport (>= 5.0) | ||
gmail_xoauth (0.4.2) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
@@ -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) | ||
|
@@ -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) | ||
|
@@ -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) | ||
|
@@ -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) | ||
|
@@ -735,6 +757,7 @@ DEPENDENCIES | |
flag_shih_tzu | ||
foreman | ||
geocoder | ||
gmail_xoauth | ||
google-cloud-dialogflow | ||
google-cloud-storage | ||
groupdate | ||
|
@@ -756,6 +779,7 @@ DEPENDENCIES | |
maxminddb | ||
mock_redis | ||
newrelic_rpm | ||
omniauth-oauth2 | ||
pg | ||
pg_search | ||
procore-sift | ||
|
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 |
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 |
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" | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
) | ||||||
|
||||||
ActiveRecord::Base.transaction do | ||||||
inbox = find_or_create_inbox | ||||||
::Redis::Alfred.delete('current_account_id') | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. let's not use |
||||||
redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
module Api::V1::Accounts::Microsoft::AuthorizationHelper | ||
end |
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 |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. let's move this to There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
There was a problem hiding this comment.
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?