microsoft365_spec.rb 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. require 'rails_helper'
  2. RSpec.describe ExternalCredential::Microsoft365 do
  3. let(:token_url) { 'https://login.microsoftonline.com/common/oauth2/v2.0/token' }
  4. let(:token_url_with_tenant) { 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token' }
  5. 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" }
  6. 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" }
  7. let(:id_token) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtnMkxZczJUMENUaklmajRydDZKSXluZW4zOCJ9.eyJhdWQiOiIyMTk4NTFhYS0wMDAwLTRhNDctMTExMS0zMmQwNzAyZTAxMjM0IiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5taWNyb3NvZnRvbmxpbmUuY29tLzM2YTlhYjU1LWZpZmEtMjAyMC04YTc4LTkwcnM0NTRkYmNmZDJkL3YyLjAiLCJpYXQiOjEzMDE1NTE4MzUsIm5iZiI6MTMwMTU1MTgzNSwiZXhwIjoxNjAxNTU5NzQ0LCJuYW1lIjoiRXhhbXBsZSBVc2VyIiwib2lkIjoiMTExYWIyMTQtMTJzNy00M2NnLThiMTItM2ozM2UydDBjYXUyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoidGVzdEBleGFtcGxlLmNvbSIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInJoIjoiMC40MjM0LWZmZnNmZGdkaGRLZUpEU1hiejlMYXBSbUNHZGdmZ2RmZ0kwZHkwSEF1QlhaSEFNYy4iLCJzdWIiOiJYY0VlcmVyQkVnX0EzNWJlc2ZkczNMTElXNjU1NFQtUy0ycGRnZ2R1Z3c1NDNXT2xJIiwidGlkIjoiMzZhOWFiNTUtZmlmYS0yMDIwLThhNzgtOTByczQ1NGRiY2ZkMmQiLCJ1dGkiOiJEU0dGZ3Nhc2RkZmdqdGpyMzV3cWVlIiwidmVyIjoiMi4wIn0=.l0nglq4rIlkR29DFK3PQFQTjE-VeHdgLmcnXwGvT8Z-QBaQjeTAcoMrVpr0WdL6SRYiyn2YuqPnxey6N0IQdlmvTMBv0X_dng_y4CiQ8ABdZrQK0VSRWZViboJgW5iBvJYFcMmVoilHChueCzTBnS1Wp2KhirS2ymUkPHS6AB98K0tzOEYciR2eJsJ2JOdo-82oOW4w6tbbqMvzT3DzsxqPQRGe2hUbNqo6gcwJLqq4t0bNf5XiYThw1sv4IivERmqW_pfybXEseKyZGd4NnJ6WwwOgTz5tkoLwls_YeDZVcp_Fpw9XR7J0UlyPqLtoUEjVihdyrJjAbdtHFKdOjrw' }
  8. let(:access_token) { '000.0000lvC3gAbjs8CYoKitfqM5LBS5N13374MCg6pNpZ28mxO2HuZvg0000_rsW00aACmFEto1BJeGDuu0000vmV6Esqv78iec-FbEe842ZevQtOOemQyQXjhMs62K1E6g3ehDLPRp6j4vtpSKSb6I-3MuDPfdzdqI23hM0' }
  9. let(:refresh_token) { '1//00000VO1ES0hFCgYIARAAGAkSNwF-L9IraWQNMj5ZTqhB00006DssAYcpEyFks5OuvZ1337wrqX0D7tE5o71FIPzcWEMM5000004' }
  10. let(:request_token) { nil } # not used but required by ExternalCredential API
  11. let(:scope_payload) { 'https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access openid profile email' }
  12. let(:scope_stub) { scope_payload }
  13. let(:client_id) { '123' }
  14. let(:client_secret) { '345' }
  15. let(:client_tenant) { 'tenant' }
  16. let(:authorization_code) { '567' }
  17. let(:email_address) { 'test@example.com' }
  18. let(:provider) { 'microsoft365' }
  19. let(:token_ttl) { 3599 }
  20. let!(:token_response_payload) do
  21. {
  22. 'access_token' => access_token,
  23. 'expires_in' => token_ttl,
  24. 'refresh_token' => refresh_token,
  25. 'scope' => scope_stub,
  26. 'token_type' => 'Bearer',
  27. 'id_token' => id_token,
  28. 'type' => 'XOAUTH2',
  29. }
  30. end
  31. describe '.link_account' do
  32. let!(:authorization_payload) do
  33. {
  34. code: authorization_code,
  35. scope: scope_payload,
  36. authuser: '4',
  37. hd: 'example.com',
  38. prompt: 'consent',
  39. controller: 'external_credentials',
  40. action: 'callback',
  41. provider: provider
  42. }
  43. end
  44. before do
  45. # we check the TTL of tokens and therefore need freeze the time
  46. freeze_time
  47. end
  48. context 'success' do
  49. let(:request_payload) do
  50. {
  51. 'client_secret' => client_secret,
  52. 'code' => authorization_code,
  53. 'grant_type' => 'authorization_code',
  54. 'client_id' => client_id,
  55. 'redirect_uri' => ExternalCredential.callback_url(provider),
  56. }
  57. end
  58. before do
  59. stub_request(:post, token_url)
  60. .with(body: hash_including(request_payload))
  61. .to_return(status: 200, body: token_response_payload.to_json, headers: {})
  62. create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret } )
  63. end
  64. it 'creates a Channel instance' do
  65. channel = described_class.link_account(request_token, authorization_payload)
  66. expect(channel.options).to match(
  67. a_hash_including(
  68. 'inbound' => a_hash_including(
  69. 'options' => a_hash_including(
  70. 'auth_type' => 'XOAUTH2',
  71. 'host' => 'outlook.office365.com',
  72. 'ssl' => true,
  73. 'user' => email_address,
  74. )
  75. ),
  76. 'outbound' => a_hash_including(
  77. 'options' => a_hash_including(
  78. 'authentication' => 'xoauth2',
  79. 'host' => 'smtp.office365.com',
  80. 'domain' => 'office365.com',
  81. 'port' => 587,
  82. 'user' => email_address,
  83. )
  84. ),
  85. 'auth' => a_hash_including(
  86. 'access_token' => access_token,
  87. 'expires_in' => token_ttl,
  88. 'refresh_token' => refresh_token,
  89. 'scope' => scope_stub,
  90. 'token_type' => 'Bearer',
  91. 'id_token' => id_token,
  92. 'created_at' => Time.zone.now,
  93. 'type' => 'XOAUTH2',
  94. 'client_id' => client_id,
  95. 'client_secret' => client_secret,
  96. ),
  97. )
  98. )
  99. end
  100. end
  101. context 'API errors' do
  102. before do
  103. stub_request(:post, token_url).to_return(status: response_status, body: response_payload&.to_json, headers: {})
  104. create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret } )
  105. end
  106. shared_examples 'failed attempt' do
  107. it 'raises an exception' do
  108. expect do
  109. described_class.link_account(request_token, authorization_payload)
  110. end.to raise_error(RuntimeError, exception_message)
  111. end
  112. end
  113. context '404 invalid_client' do
  114. let(:response_status) { 404 }
  115. let(:response_payload) do
  116. {
  117. error: 'invalid_client',
  118. error_description: 'The OAuth client was not found.'
  119. }
  120. end
  121. let(:exception_message) { 'Request failed! ERROR: invalid_client (The OAuth client was not found.)' }
  122. include_examples 'failed attempt'
  123. end
  124. context '500 Internal Server Error' do
  125. let(:response_status) { 500 }
  126. let(:response_payload) { nil }
  127. let(:exception_message) { 'Request failed! (code: 500)' }
  128. include_examples 'failed attempt'
  129. end
  130. end
  131. end
  132. describe '.refresh_token' do
  133. let!(:authorization_payload) do
  134. {
  135. code: authorization_code,
  136. scope: scope_payload,
  137. authuser: '4',
  138. hd: 'example.com',
  139. prompt: 'consent',
  140. controller: 'external_credentials',
  141. action: 'callback',
  142. provider: provider
  143. }
  144. end
  145. let!(:channel) do
  146. stub_request(:post, token_url).to_return(status: 200, body: token_response_payload.to_json, headers: {})
  147. create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret } )
  148. channel = described_class.link_account(request_token, authorization_payload)
  149. # remove stubs and allow new stubbing for tested requests
  150. WebMock.reset!
  151. channel
  152. end
  153. before do
  154. # we check the TTL of tokens and therefore need freeze the time
  155. freeze_time
  156. end
  157. context 'success' do
  158. let!(:expired_at) { channel.options['auth']['created_at'] }
  159. before do
  160. stub_request(:post, token_url).to_return(status: 200, body: response_payload.to_json, headers: {})
  161. end
  162. context 'access_token still valid' do
  163. let(:response_payload) do
  164. {
  165. 'access_token' => access_token,
  166. 'expires_in' => token_ttl,
  167. 'scope' => scope_stub,
  168. 'token_type' => 'Bearer',
  169. 'type' => 'XOAUTH2',
  170. }
  171. end
  172. it 'does not refresh' do
  173. expect do
  174. channel.refresh_xoauth2!
  175. end.not_to change { channel.options['auth']['created_at'] }
  176. end
  177. end
  178. context 'access_token expired' do
  179. let(:refreshed_access_token) { 'some_new_token' }
  180. let(:response_payload) do
  181. {
  182. 'access_token' => refreshed_access_token,
  183. 'expires_in' => token_ttl,
  184. 'scope' => scope_stub,
  185. 'token_type' => 'Bearer',
  186. 'type' => 'XOAUTH2',
  187. }
  188. end
  189. before do
  190. travel 1.hour
  191. end
  192. it 'refreshes token' do
  193. expect do
  194. channel.refresh_xoauth2!
  195. end.to change { channel.options['auth'] }.to a_hash_including(
  196. 'created_at' => Time.zone.now,
  197. 'access_token' => refreshed_access_token,
  198. )
  199. end
  200. end
  201. end
  202. context 'API errors' do
  203. before do
  204. stub_request(:post, token_url).to_return(status: response_status, body: response_payload&.to_json, headers: {})
  205. # invalidate existing token
  206. travel 1.hour
  207. end
  208. shared_examples 'failed attempt' do
  209. it 'raises an exception' do
  210. expect do
  211. channel.refresh_xoauth2!
  212. end.to raise_error(RuntimeError, exception_message)
  213. end
  214. end
  215. context '400 invalid_client' do
  216. let(:response_status) { 400 }
  217. let(:response_payload) do
  218. {
  219. error: 'invalid_client',
  220. error_description: 'The OAuth client was not found.'
  221. }
  222. end
  223. let(:exception_message) { %r{The OAuth client was not found} }
  224. include_examples 'failed attempt'
  225. end
  226. context '500 Internal Server Error' do
  227. let(:response_status) { 500 }
  228. let(:response_payload) { nil }
  229. let(:exception_message) { %r{code: 500} }
  230. include_examples 'failed attempt'
  231. end
  232. end
  233. end
  234. describe '.request_account_to_link' do
  235. it 'generates authorize_url from credentials' do
  236. microsoft365 = create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret } )
  237. request = described_class.request_account_to_link(microsoft365.credentials)
  238. expect(request[:authorize_url]).to eq(authorize_url)
  239. end
  240. context 'errors' do
  241. shared_examples 'failed attempt' do
  242. it 'raises an exception' do
  243. expect do
  244. described_class.request_account_to_link(credentials, app_required)
  245. end.to raise_error(Exceptions::UnprocessableEntity, exception_message)
  246. end
  247. end
  248. context 'missing credentials' do
  249. let(:credentials) { nil }
  250. let(:app_required) { true }
  251. let(:exception_message) { 'No Microsoft365 app configured!' }
  252. include_examples 'failed attempt'
  253. end
  254. context 'missing client_id' do
  255. let(:credentials) do
  256. {
  257. client_secret: client_secret
  258. }
  259. end
  260. let(:app_required) { false }
  261. let(:exception_message) { 'No client_id param!' }
  262. include_examples 'failed attempt'
  263. end
  264. context 'missing client_secret' do
  265. let(:credentials) do
  266. {
  267. client_id: client_id
  268. }
  269. end
  270. let(:app_required) { false }
  271. let(:exception_message) { 'No client_secret param!' }
  272. include_examples 'failed attempt'
  273. end
  274. end
  275. end
  276. describe '.generate_authorize_url' do
  277. it 'generates valid URL' do
  278. url = described_class.generate_authorize_url(client_id: client_id)
  279. expect(url).to eq(authorize_url)
  280. end
  281. it 'generates valid URL with tenant' do
  282. url = described_class.generate_authorize_url(client_id: client_id, client_tenant: 'tenant')
  283. expect(url).to eq(authorize_url_with_tenant)
  284. end
  285. end
  286. describe '.user_info' do
  287. it 'extracts user information from id_token' do
  288. info = described_class.user_info(id_token)
  289. expect(info[:email]).to eq(email_address)
  290. end
  291. end
  292. end