Compare commits

...
Author SHA1 Message Date
Shivam Mishra d0ebd55b54 fix: scope 2025-06-09 19:32:56 +05:30
Shivam MishraandGitHub 8ba26b3541 Merge branch 'feat/use-oauth-state' into feat/oauth-scope-verification 2025-06-09 18:21:00 +05:30
Shivam Mishra 0ad16cf115 style: reduce line length 2025-06-09 18:20:38 +05:30
Shivam MishraandGitHub 9af966422f Merge branch 'feat/use-oauth-state' into feat/oauth-scope-verification 2025-06-09 18:05:05 +05:30
Shivam Mishra 795310d549 fix: ms specs 2025-06-09 18:04:47 +05:30
Shivam MishraandGitHub 1892c492cf Merge branch 'feat/use-oauth-state' into feat/oauth-scope-verification 2025-06-09 17:36:34 +05:30
Shivam Mishra a74e75e458 Merge branch 'feat/use-oauth-state' of github.com:chatwoot/chatwoot into feat/use-oauth-state 2025-06-09 17:36:14 +05:30
Shivam Mishra c1bdff2e35 test: handle state 2025-06-09 17:35:42 +05:30
Shivam MishraandGitHub 5f5cad5445 Merge branch 'feat/use-oauth-state' into feat/oauth-scope-verification 2025-06-09 16:52:48 +05:30
Shivam Mishra 5649333abd test: handle state 2025-06-09 16:51:13 +05:30
Shivam MishraandGitHub 6ce140af06 Merge branch 'feat/use-oauth-state' into feat/oauth-scope-verification 2025-06-09 16:11:44 +05:30
Shivam Mishra 09c6fd4ea5 test: handle new state 2025-06-09 16:11:30 +05:30
Shivam MishraandGitHub 60d7da9488 Merge branch 'feat/use-oauth-state' into feat/oauth-scope-verification 2025-06-09 16:06:12 +05:30
9b3148e16d feat: ensure account is present
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-09 16:06:04 +05:30
2584d6dcba chore: raise a specific error
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-09 16:02:45 +05:30
Shivam Mishra f7e3c2a19a refactor: simplify error handling 2025-06-09 15:36:46 +05:30
Shivam Mishra a6e28b71d1 chore: remove unused errors 2025-06-09 15:35:47 +05:30
Shivam Mishra f4b8760e00 feat: handle oauth scope verification and errors 2025-06-09 15:35:42 +05:30
Shivam Mishra 43e55491a3 feat: separate scope methods 2025-06-09 15:23:03 +05:30
Shivam Mishra 85ac5e75a1 refactor: common oauth_authorization_controller 2025-06-09 15:23:03 +05:30
Shivam Mishra 5722a310c9 feat: use state for authorization instead of caching email 2025-06-09 15:23:03 +05:30
17 changed files with 152 additions and 111 deletions
@@ -1,32 +1,23 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include GoogleConcern include GoogleConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url( redirect_url = google_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/google/callback", redirect_uri: "#{base_url}/google/callback",
scope: 'email profile https://mail.google.com/', scope: scope,
response_type: 'code', response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online' access_type: 'offline', # the default is 'online'
state: state,
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
} }
) )
if redirect_url if redirect_url
cache_key = "google::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,7 +1,6 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include InstagramConcern include InstagramConcern
include Instagram::IntegrationHelper include Instagram::IntegrationHelper
before_action :check_authorization
def create def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
@@ -21,10 +20,4 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,28 +1,19 @@
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include MicrosoftConcern include MicrosoftConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = microsoft_client.auth_code.authorize_url( redirect_url = microsoft_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/microsoft/callback", redirect_uri: "#{base_url}/microsoft/callback",
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile', scope: scope,
state: state,
prompt: 'consent' prompt: 'consent'
} }
) )
if redirect_url if redirect_url
cache_key = "microsoft::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -0,0 +1,23 @@
class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController
before_action :check_authorization
protected
def scope
''
end
def state
Current.account.to_sgid(expires_in: 15.minutes).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
+2 -2
View File
@@ -14,7 +14,7 @@ module GoogleConcern
private private
def base_url def scope
ENV.fetch('FRONTEND_URL', 'http://localhost:3000') 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email https://mail.google.com/'
end end
end end
@@ -15,7 +15,7 @@ module MicrosoftConcern
private private
def base_url def scope
ENV.fetch('FRONTEND_URL', 'http://localhost:3000') 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile'
end end
end end
@@ -12,6 +12,16 @@ class Google::CallbacksController < OauthCallbackController
private private
def verify_scopes
granted_scopes = parsed_body['scope']&.split || []
required_scopes = scope.split
missing_scopes = required_scopes - granted_scopes
return if missing_scopes.empty?
raise StandardError, I18n.t('errors.oauth.insufficient_scopes', scopes: missing_scopes.join(', '))
end
def provider_name def provider_name
'google' 'google'
end end
@@ -24,4 +34,13 @@ class Google::CallbacksController < OauthCallbackController
# from GoogleConcern # from GoogleConcern
google_client google_client
end end
def handle_error(exception)
ChatwootExceptionTracker.new(exception).capture_exception
error_message = exception.message || I18n.t('errors.oauth.authorization_failed')
redirect_url = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/inboxes/new/email"
redirect_to "#{redirect_url}?error=#{CGI.escape(error_message)}"
end
end end
+21 -8
View File
@@ -5,11 +5,10 @@ class OauthCallbackController < ApplicationController
redirect_uri: "#{base_url}/#{provider_name}/callback" redirect_uri: "#{base_url}/#{provider_name}/callback"
) )
verify_scopes
handle_response handle_response
::Redis::Alfred.delete(cache_key)
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception handle_error(e)
redirect_to '/'
end end
private private
@@ -64,8 +63,17 @@ class OauthCallbackController < ApplicationController
raise NotImplementedError raise NotImplementedError
end end
def cache_key def verify_scopes
"#{provider_name}::#{users_data['email'].downcase}" true
end
def failure_redirect_url
'/'
end
def handle_error(exception)
ChatwootExceptionTracker.new(exception).capture_exception
redirect_to failure_redirect_url
end end
def create_channel_with_inbox def create_channel_with_inbox
@@ -86,12 +94,17 @@ class OauthCallbackController < ApplicationController
decoded_token[0] decoded_token[0]
end end
def account_id def account_from_signed_id
::Redis::Alfred.get(cache_key) raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
account = GlobalID::Locator.locate_signed(params[:state])
raise 'Invalid or expired state' if account.nil?
account
end end
def account def account
@account ||= Account.find(account_id) @account ||= account_from_signed_id
end end
# Fallback name, for when name field is missing from users_data # Fallback name, for when name field is missing from users_data
@@ -1,17 +1,21 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed, onMounted } from 'vue';
import ForwardToOption from './emailChannels/ForwardToOption.vue'; import ForwardToOption from './emailChannels/ForwardToOption.vue';
import Microsoft from './emailChannels/Microsoft.vue'; import Microsoft from './emailChannels/Microsoft.vue';
import Google from './emailChannels/Google.vue'; import Google from './emailChannels/Google.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue'; import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import PageHeader from '../../SettingsSubPageHeader.vue'; import PageHeader from '../../SettingsSubPageHeader.vue';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store'; import { useStoreGetters } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const provider = ref(''); const provider = ref('');
const getters = useStoreGetters(); const getters = useStoreGetters();
const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const globalConfig = getters['globalConfig/get']; const globalConfig = getters['globalConfig/get'];
@@ -45,6 +49,15 @@ const emailProviderList = computed(() => {
}); });
}); });
const checkForOAuthError = () => {
const { error } = route.query;
if (error) {
useAlert(decodeURIComponent(error));
}
};
onMounted(checkForOAuthError);
function onClick(emailProvider) { function onClick(emailProvider) {
if (emailProvider.isEnabled) { if (emailProvider.isEnabled) {
provider.value = emailProvider.key; provider.value = emailProvider.key;
@@ -12,7 +12,6 @@ defineOptions({
provider="google" provider="google"
:title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')" :title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')"
:description="$t('INBOX_MGMT.ADD.GOOGLE.DESCRIPTION')" :description="$t('INBOX_MGMT.ADD.GOOGLE.DESCRIPTION')"
:input-placeholder="$t('INBOX_MGMT.ADD.GOOGLE.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.GOOGLE.SIGN_IN')" :submit-button-text="$t('INBOX_MGMT.ADD.GOOGLE.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')" :error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')"
/> />
@@ -12,7 +12,6 @@ defineOptions({
provider="microsoft" provider="microsoft"
:title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')" :title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')"
:description="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')" :description="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')"
:input-placeholder="$t('INBOX_MGMT.ADD.MICROSOFT.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')" :submit-button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')" :error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')"
/> />
@@ -30,14 +30,9 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
inputPlaceholder: {
type: String,
required: true,
},
}); });
const isRequestingAuthorization = ref(false); const isRequestingAuthorization = ref(false);
const email = ref('');
const client = computed(() => { const client = computed(() => {
if (props.provider === 'microsoft') { if (props.provider === 'microsoft') {
@@ -50,9 +45,7 @@ const client = computed(() => {
async function requestAuthorization() { async function requestAuthorization() {
try { try {
isRequestingAuthorization.value = true; isRequestingAuthorization.value = true;
const response = await client.value.generateAuthorization({ const response = await client.value.generateAuthorization();
email: email.value,
});
const { const {
data: { url }, data: { url },
} = response; } = response;
@@ -75,11 +68,6 @@ async function requestAuthorization() {
:header-content="description" :header-content="description"
/> />
<form class="mt-6" @submit.prevent="requestAuthorization"> <form class="mt-6" @submit.prevent="requestAuthorization">
<woot-input
v-model="email"
type="email"
:placeholder="inputPlaceholder"
/>
<NextButton <NextButton
:is-loading="isRequestingAuthorization" :is-loading="isRequestingAuthorization"
type="submit" type="submit"
+3
View File
@@ -48,6 +48,9 @@ en:
email_already_exists: 'You have already signed up for an account with %{email}' email_already_exists: 'You have already signed up for an account with %{email}'
invalid_params: 'Invalid, please check the signup paramters and try again' invalid_params: 'Invalid, please check the signup paramters and try again'
failed: Signup failed failed: Signup failed
oauth:
insufficient_scopes: 'Insufficient permissions granted. Missing scopes: %{scopes}'
authorization_failed: 'Authorization failed'
data_import: data_import:
data_type: data_type:
invalid: Invalid data type invalid: Invalid data type
@@ -32,19 +32,23 @@ RSpec.describe 'Google Authorization API', type: :request do
as: :json as: :json
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
google_service = Class.new { extend GoogleConcern }
response_url = google_service.google_client.auth_code.authorize_url( # Validate URL components
{ url = response.parsed_body['url']
redirect_uri: "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback", uri = URI.parse(url)
scope: 'email profile https://mail.google.com/', params = CGI.parse(uri.query)
response_type: 'code',
prompt: 'consent', expect(url).to start_with('https://accounts.google.com/o/oauth2/auth')
access_type: 'offline', expected_scope = 'https://www.googleapis.com/auth/userinfo.profile ' \
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) 'https://www.googleapis.com/auth/userinfo.email ' \
} 'https://mail.google.com/'
) expect(params['scope']).to eq([expected_scope])
expect(response.parsed_body['url']).to eq response_url expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback"])
expect(Redis::Alfred.get("google::#{administrator.email}")).to eq(account.id.to_s)
# Validate state parameter exists and can be decoded back to the account
expect(params['state']).to be_present
decoded_account = GlobalID::Locator.locate_signed(params['state'].first, for: 'default')
expect(decoded_account).to eq(account)
end end
end end
end end
@@ -19,7 +19,6 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
it 'returns unathorized for agent' do it 'returns unathorized for agent' do
post "/api/v1/accounts/#{account.id}/microsoft/authorization", post "/api/v1/accounts/#{account.id}/microsoft/authorization",
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
params: { email: administrator.email },
as: :json as: :json
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
@@ -28,20 +27,27 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
it 'creates a new authorization and returns the redirect url' do it 'creates a new authorization and returns the redirect url' do
post "/api/v1/accounts/#{account.id}/microsoft/authorization", post "/api/v1/accounts/#{account.id}/microsoft/authorization",
headers: administrator.create_new_auth_token, headers: administrator.create_new_auth_token,
params: { email: administrator.email },
as: :json as: :json
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
microsoft_service = Class.new { extend MicrosoftConcern }
response_url = microsoft_service.microsoft_client.auth_code.authorize_url( # Validate URL components
{ url = response.parsed_body['url']
redirect_uri: "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback", uri = URI.parse(url)
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile', params = CGI.parse(uri.query)
prompt: 'consent'
} expect(url).to start_with('https://login.microsoftonline.com/common/oauth2/v2.0/authorize')
) expected_scope = [
expect(response.parsed_body['url']).to eq response_url 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All ' \
expect(Redis::Alfred.get("microsoft::#{administrator.email}")).to eq(account.id.to_s) 'https://outlook.office.com/SMTP.Send openid profile'
]
expect(params['scope']).to eq(expected_scope)
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
# Validate state parameter exists and can be decoded back to the account
expect(params['state']).to be_present
decoded_account = GlobalID::Locator.locate_signed(params['state'].first, for: 'default')
expect(decoded_account).to eq(account)
end end
end end
end end
@@ -4,11 +4,7 @@ RSpec.describe 'Google::CallbacksController', type: :request do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:code) { SecureRandom.hex(10) } let(:code) { SecureRandom.hex(10) }
let(:email) { Faker::Internet.email } let(:email) { Faker::Internet.email }
let(:cache_key) { "google::#{email.downcase}" } let(:state) { account.to_sgid(expires_in: 15.minutes).to_s }
before do
Redis::Alfred.set(cache_key, account.id)
end
describe 'GET /google/callback' do describe 'GET /google/callback' do
let(:response_body_success) do let(:response_body_success) do
@@ -27,7 +23,7 @@ RSpec.describe 'Google::CallbacksController', type: :request do
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" }) 'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
.to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' }) .to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' })
get google_callback_url, params: { code: code } get google_callback_url, params: { code: code, state: state }
expect(response).to redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id) expect(response).to redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id)
expect(account.inboxes.count).to be 1 expect(account.inboxes.count).to be 1
@@ -36,7 +32,6 @@ RSpec.describe 'Google::CallbacksController', type: :request do
expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on') expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on')
expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token] expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token]
expect(inbox.channel.imap_address).to eq 'imap.gmail.com' expect(inbox.channel.imap_address).to eq 'imap.gmail.com'
expect(Redis::Alfred.get(cache_key)).to be_nil
end end
it 'updates inbox channel config if inbox exists with imap_login and authentication is successful' do it 'updates inbox channel config if inbox exists with imap_login and authentication is successful' do
@@ -49,14 +44,13 @@ RSpec.describe 'Google::CallbacksController', type: :request do
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" }) 'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
.to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' }) .to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' })
get google_callback_url, params: { code: code } get google_callback_url, params: { code: code, state: state }
expect(response).to redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id) expect(response).to redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
expect(account.inboxes.count).to be 1 expect(account.inboxes.count).to be 1
expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on') expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on')
expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token] expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token]
expect(inbox.channel.imap_address).to eq 'imap.gmail.com' expect(inbox.channel.imap_address).to eq 'imap.gmail.com'
expect(Redis::Alfred.get(cache_key)).to be_nil
end end
it 'creates inboxes with fallback_name when account name is not present in id_token' do it 'creates inboxes with fallback_name when account name is not present in id_token' do
@@ -65,7 +59,7 @@ RSpec.describe 'Google::CallbacksController', type: :request do
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" }) 'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
.to_return(status: 200, body: response_body_success_without_name.to_json, headers: { 'Content-Type' => 'application/json' }) .to_return(status: 200, body: response_body_success_without_name.to_json, headers: { 'Content-Type' => 'application/json' })
get google_callback_url, params: { code: code } get google_callback_url, params: { code: code, state: state }
expect(response).to redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id) expect(response).to redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id)
expect(account.inboxes.count).to be 1 expect(account.inboxes.count).to be 1
@@ -73,16 +67,28 @@ RSpec.describe 'Google::CallbacksController', type: :request do
expect(inbox.name).to eq email.split('@').first.parameterize.titleize expect(inbox.name).to eq email.split('@').first.parameterize.titleize
end end
it 'redirects to google app in case of error' do it 'redirects to custom error URL when insufficient scopes are granted' do
stub_request(:post, 'https://accounts.google.com/o/oauth2/token')
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
.to_return(status: 200, body: response_body_insufficient_scopes.to_json, headers: { 'Content-Type' => 'application/json' })
get google_callback_url, params: { code: code }
expect(response).to redirect_to(/#{Regexp.escape("#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/inboxes/new/email")}/)
expect(response.location).to include('error=')
end
it 'redirects to custom error URL in case of OAuth error' do
stub_request(:post, 'https://accounts.google.com/o/oauth2/token') stub_request(:post, 'https://accounts.google.com/o/oauth2/token')
.with(body: { 'code' => code, 'grant_type' => 'authorization_code', .with(body: { 'code' => code, 'grant_type' => 'authorization_code',
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" }) 'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
.to_return(status: 401) .to_return(status: 401)
get google_callback_url, params: { code: code } get google_callback_url, params: { code: code, state: state }
expect(response).to redirect_to '/' expect(response).to redirect_to(/#{Regexp.escape("#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/inboxes/new/email")}/)
expect(Redis::Alfred.get(cache_key).to_i).to eq account.id expect(response.location).to include('error=')
end end
end end
end end
@@ -4,11 +4,7 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:code) { SecureRandom.hex(10) } let(:code) { SecureRandom.hex(10) }
let(:email) { Faker::Internet.email } let(:email) { Faker::Internet.email }
let(:cache_key) { "microsoft::#{email.downcase}" } let(:state) { account.to_sgid(expires_in: 15.minutes).to_s }
before do
Redis::Alfred.set(cache_key, account.id)
end
describe 'GET /microsoft/callback' do describe 'GET /microsoft/callback' do
let(:response_body_success) do let(:response_body_success) do
@@ -27,7 +23,7 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" }) 'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
.to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' }) .to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' })
get microsoft_callback_url, params: { code: code } get microsoft_callback_url, params: { code: code, state: state }
expect(response).to redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id) expect(response).to redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id)
expect(account.inboxes.count).to be 1 expect(account.inboxes.count).to be 1
@@ -36,7 +32,6 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on') expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on')
expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token] expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token]
expect(inbox.channel.imap_address).to eq 'outlook.office365.com' expect(inbox.channel.imap_address).to eq 'outlook.office365.com'
expect(Redis::Alfred.get(cache_key)).to be_nil
end end
it 'creates updates inbox channel config if inbox exists and authentication is successful' do it 'creates updates inbox channel config if inbox exists and authentication is successful' do
@@ -48,14 +43,13 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" }) 'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
.to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' }) .to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' })
get microsoft_callback_url, params: { code: code } get microsoft_callback_url, params: { code: code, state: state }
expect(response).to redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: account.inboxes.last.id) expect(response).to redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: account.inboxes.last.id)
expect(account.inboxes.count).to be 1 expect(account.inboxes.count).to be 1
expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on') expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on')
expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token] expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token]
expect(inbox.channel.imap_address).to eq 'outlook.office365.com' expect(inbox.channel.imap_address).to eq 'outlook.office365.com'
expect(Redis::Alfred.get(cache_key)).to be_nil
end end
it 'creates inboxes with fallback_name when account name is not present in id_token' do it 'creates inboxes with fallback_name when account name is not present in id_token' do
@@ -64,7 +58,7 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" }) 'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
.to_return(status: 200, body: response_body_success_without_name.to_json, headers: { 'Content-Type' => 'application/json' }) .to_return(status: 200, body: response_body_success_without_name.to_json, headers: { 'Content-Type' => 'application/json' })
get microsoft_callback_url, params: { code: code } get microsoft_callback_url, params: { code: code, state: state }
expect(response).to redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id) expect(response).to redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id)
expect(account.inboxes.count).to be 1 expect(account.inboxes.count).to be 1
@@ -78,10 +72,9 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" }) 'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
.to_return(status: 401) .to_return(status: 401)
get microsoft_callback_url, params: { code: code } get microsoft_callback_url, params: { code: code, state: state }
expect(response).to redirect_to '/' expect(response).to redirect_to '/'
expect(Redis::Alfred.get(cache_key).to_i).to eq account.id
end end
end end
end end