microsoft_base.rb 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. # Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
  2. class ExternalCredential::MicrosoftBase
  3. def self.channel_area
  4. raise NotImplementedError
  5. end
  6. def self.provider_name
  7. name.demodulize.underscore
  8. end
  9. def self.error_missing_app_configuration
  10. raise NotImplementedError
  11. end
  12. def self.authorize_scope
  13. raise NotImplementedError
  14. end
  15. def self.channel_options_inbound(user_data)
  16. raise NotImplementedError
  17. end
  18. def self.channel_options_outbound(user_data)
  19. raise NotImplementedError
  20. end
  21. def self.channel_migration_possible?
  22. false
  23. end
  24. def self.app_verify(params)
  25. request_account_to_link(params, false)
  26. params
  27. end
  28. def self.request_account_to_link(credentials = {}, app_required = true)
  29. external_credential = ExternalCredential.find_by(name: provider_name)
  30. raise Exceptions::UnprocessableEntity, error_missing_app_configuration if !external_credential && app_required
  31. if external_credential
  32. if credentials[:client_id].blank?
  33. credentials[:client_id] = external_credential.credentials['client_id']
  34. end
  35. if credentials[:client_secret].blank?
  36. credentials[:client_secret] = external_credential.credentials['client_secret']
  37. end
  38. # client_tenant may be empty. Set only if key is nonexistant at all
  39. if !credentials.key? :client_tenant
  40. credentials[:client_tenant] = external_credential.credentials['client_tenant']
  41. end
  42. end
  43. raise Exceptions::UnprocessableEntity, __("The required parameter 'client_id' is missing.") if credentials[:client_id].blank?
  44. raise Exceptions::UnprocessableEntity, __("The required parameter 'client_secret' is missing.") if credentials[:client_secret].blank?
  45. authorize_url = generate_authorize_url(credentials)
  46. {
  47. authorize_url: authorize_url,
  48. }
  49. end
  50. def self.link_account(_request_token, params)
  51. # return to admin interface if admin Consent is in process and user clicks on "Back to app"
  52. return "#{Setting.get('http_type')}://#{Setting.get('fqdn')}/#channels/#{provider_name}/error/AADSTS65004" if params[:error_description].present? && params[:error_description].include?('AADSTS65004')
  53. external_credential = ExternalCredential.find_by(name: provider_name)
  54. raise Exceptions::UnprocessableEntity, error_missing_app_configuration if !external_credential
  55. raise Exceptions::UnprocessableEntity, __("The required parameter 'code' is missing.") if !params[:code]
  56. response = authorize_tokens(external_credential.credentials, params[:code])
  57. %w[refresh_token access_token expires_in scope token_type id_token].each do |key|
  58. raise Exceptions::UnprocessableEntity, "No #{key} for authorization request found!" if response[key.to_sym].blank?
  59. end
  60. user_data = user_info(response[:id_token])
  61. raise Exceptions::UnprocessableEntity, __("The user's 'preferred_username' could not be extracted from 'id_token'.") if user_data[:preferred_username].blank?
  62. account_data = {}
  63. # Restore shared mailbox information from session and clean it up.
  64. if params[:shared_mailbox].present?
  65. account_data[:shared_mailbox] = params[:shared_mailbox]
  66. end
  67. channel_options = {
  68. inbound: channel_options_inbound(user_data, account_data),
  69. outbound: channel_options_outbound(user_data),
  70. auth: response.merge(
  71. provider: provider_name,
  72. type: 'XOAUTH2',
  73. client_id: external_credential.credentials[:client_id],
  74. client_secret: external_credential.credentials[:client_secret],
  75. client_tenant: external_credential.credentials[:client_tenant],
  76. ),
  77. }
  78. if params[:channel_id]
  79. existing_channel = Channel.where(area: channel_area).find(params[:channel_id])
  80. # Check if current user of the channel is matching the user from the token.
  81. # Allow mismatch in case of a shared mailbox, since multiple users may be able access the same mailbox.
  82. # In this case, inbound probe should verify if everything still works as expected.
  83. token_user = user_data[:preferred_username]&.downcase
  84. inbound_user = channel_user(existing_channel, :inbound)&.downcase
  85. outbound_user = channel_user(existing_channel, :outbound)&.downcase
  86. shared_mailbox = channel_shared_mailbox(existing_channel)
  87. if ((inbound_user.present? && inbound_user != token_user) || (outbound_user.present? && outbound_user != token_user)) && shared_mailbox.blank?
  88. return "#{Setting.get('http_type')}://#{Setting.get('fqdn')}/#channels/#{provider_name}/error/user_mismatch/channel/#{existing_channel.id}"
  89. end
  90. channel_options[:inbound][:options][:shared_mailbox] = shared_mailbox if shared_mailbox.present?
  91. channel_options[:inbound][:options][:folder] = existing_channel.options[:inbound][:options][:folder]
  92. channel_options[:inbound][:options][:keep_on_server] = existing_channel.options[:inbound][:options][:keep_on_server]
  93. existing_channel.update!(
  94. options: channel_options,
  95. )
  96. existing_channel.refresh_xoauth2!
  97. return existing_channel
  98. end
  99. if channel_migration_possible?
  100. migration_channel = find_migration_channel(user_data)
  101. return execute_channel_migration(migrate_channel, channel_options) if migration_channel
  102. end
  103. email_address = {
  104. name: "#{Setting.get('product_name')} Support",
  105. email: account_data[:shared_mailbox] || user_data[:preferred_username],
  106. }
  107. existing_email_address = EmailAddress.where(email: email_address[:email])
  108. # Check if a bound address with the same email already exists.
  109. if existing_email_address.where.not(channel: nil).exists?
  110. return "#{Setting.get('http_type')}://#{Setting.get('fqdn')}/#channels/#{provider_name}/error/duplicate_email_address/param/#{CGI.escapeURIComponent(email_address[:email])}"
  111. end
  112. # create channel
  113. channel = Channel.create!(
  114. area: channel_area,
  115. group_id: Group.first.id,
  116. options: channel_options,
  117. active: false,
  118. created_by_id: 1,
  119. updated_by_id: 1,
  120. )
  121. # Assign an email address to the channel by either creating a new or repurposing an existing one.
  122. if existing_email_address.exists?
  123. existing_email_address.update!(
  124. channel_id: channel.id,
  125. name: email_address[:name],
  126. email: email_address[:email],
  127. active: true,
  128. created_by_id: 1,
  129. updated_by_id: 1,
  130. )
  131. else
  132. EmailAddress.create_or_update(
  133. channel_id: channel.id,
  134. name: email_address[:name],
  135. email: email_address[:email],
  136. active: true,
  137. created_by_id: 1,
  138. updated_by_id: 1,
  139. )
  140. end
  141. channel
  142. end
  143. def self.generate_authorize_url(credentials, scope = authorize_scope)
  144. # TODO: should we add recoomended "state" parameter here for security reasons?
  145. params = {
  146. 'client_id' => credentials[:client_id],
  147. 'redirect_uri' => ExternalCredential.callback_url(provider_name),
  148. 'scope' => scope,
  149. 'response_type' => 'code',
  150. 'access_type' => 'offline',
  151. 'prompt' => credentials[:prompt] || 'login',
  152. }
  153. tenant = credentials[:client_tenant].presence || 'common'
  154. uri = URI::HTTPS.build(
  155. host: 'login.microsoftonline.com',
  156. path: "/#{tenant}/oauth2/v2.0/authorize",
  157. query: params.to_query
  158. )
  159. uri.to_s
  160. end
  161. def self.authorize_tokens(credentials, authorization_code)
  162. uri = authorize_tokens_uri(credentials[:client_tenant])
  163. params = authorize_tokens_params(credentials, authorization_code)
  164. response = UserAgent.post(uri.to_s, params)
  165. if response.code != 200 && response.body.blank?
  166. Rails.logger.error "Request failed! (code: #{response.code})"
  167. raise "Request failed! (code: #{response.code})"
  168. end
  169. result = JSON.parse(response.body)
  170. if result['error'] && response.code != 200
  171. Rails.logger.error "Request failed! ERROR: #{result['error']} (#{result['error_description']}, params: #{params.to_json})"
  172. raise "Request failed! ERROR: #{result['error']} (#{result['error_description']})"
  173. end
  174. result[:created_at] = Time.zone.now
  175. result.symbolize_keys
  176. end
  177. def self.authorize_tokens_params(credentials, authorization_code)
  178. {
  179. client_secret: credentials[:client_secret],
  180. code: authorization_code,
  181. grant_type: 'authorization_code',
  182. client_id: credentials[:client_id],
  183. redirect_uri: ExternalCredential.callback_url(provider_name),
  184. }
  185. end
  186. def self.authorize_tokens_uri(tenant)
  187. URI::HTTPS.build(
  188. host: 'login.microsoftonline.com',
  189. path: "/#{tenant.presence || 'common'}/oauth2/v2.0/token",
  190. )
  191. end
  192. def self.refresh_token(token)
  193. return token if token[:created_at] >= 50.minutes.ago
  194. params = refresh_token_params(token)
  195. uri = refresh_token_uri(token)
  196. response = UserAgent.post(uri.to_s, params)
  197. if response.code != 200 && response.body.blank?
  198. Rails.logger.error "Request failed! (code: #{response.code})"
  199. raise "Request failed! (code: #{response.code})"
  200. end
  201. result = JSON.parse(response.body)
  202. if result['error'] && response.code != 200
  203. Rails.logger.error "Request failed! ERROR: #{result['error']} (#{result['error_description']}, params: #{params.to_json})"
  204. raise "Request failed! ERROR: #{result['error']} (#{result['error_description']})"
  205. end
  206. token.merge(result.symbolize_keys).merge(
  207. created_at: Time.zone.now,
  208. )
  209. end
  210. def self.refresh_token_params(credentials)
  211. {
  212. client_id: credentials[:client_id],
  213. client_secret: credentials[:client_secret],
  214. refresh_token: credentials[:refresh_token],
  215. grant_type: 'refresh_token',
  216. }
  217. end
  218. def self.refresh_token_uri(credentials)
  219. tenant = credentials[:client_tenant].presence || 'common'
  220. URI::HTTPS.build(
  221. host: 'login.microsoftonline.com',
  222. path: "/#{tenant}/oauth2/v2.0/token",
  223. )
  224. end
  225. def self.channel_user(channel, key)
  226. channel.options.dig(key.to_sym, :options, :user)
  227. end
  228. def self.channel_shared_mailbox(channel)
  229. channel.options.dig(:inbound, :options, :shared_mailbox)
  230. end
  231. def self.user_info(id_token)
  232. split = id_token.split('.')[1]
  233. return if split.blank?
  234. JSON.parse(Base64.decode64(split)).symbolize_keys
  235. end
  236. end