microsoft_graph_spec.rb 14 KB

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