mirror of
https://github.com/zammad/zammad
synced 2026-05-24 09:48:36 +00:00
332 lines
12 KiB
Ruby
332 lines
12 KiB
Ruby
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
|
|
|
class ExternalCredential::MicrosoftBase < ExternalCredential::Base::ChannelXoauth2
|
|
def self.provider_name
|
|
name.demodulize.underscore
|
|
end
|
|
|
|
def self.error_missing_app_configuration
|
|
raise NotImplementedError
|
|
end
|
|
|
|
def self.authorize_scope
|
|
raise NotImplementedError
|
|
end
|
|
|
|
def self.channel_options_inbound(user_data, account_data)
|
|
raise NotImplementedError
|
|
end
|
|
|
|
def self.channel_options_outbound(user_data, account_data)
|
|
raise NotImplementedError
|
|
end
|
|
|
|
def self.channel_migration_possible?
|
|
false
|
|
end
|
|
|
|
def self.app_verify(params)
|
|
request_account_to_link(params, false)
|
|
params
|
|
end
|
|
|
|
def self.request_account_to_link(credentials = {}, app_required = true)
|
|
external_credential = ExternalCredential.find_by(name: provider_name)
|
|
raise Exceptions::UnprocessableContent, error_missing_app_configuration if !external_credential && app_required
|
|
|
|
if external_credential
|
|
if credentials[:client_id].blank?
|
|
credentials[:client_id] = external_credential.credentials['client_id']
|
|
end
|
|
if credentials[:client_secret].blank?
|
|
credentials[:client_secret] = external_credential.credentials['client_secret']
|
|
end
|
|
# client_tenant may be empty. Set only if key is nonexistant at all
|
|
if !credentials.key? :client_tenant
|
|
credentials[:client_tenant] = external_credential.credentials['client_tenant']
|
|
end
|
|
# multi_tenant_app may be false. Set only if key is nonexistent at all
|
|
if !credentials.key? :multi_tenant_app
|
|
credentials[:multi_tenant_app] = external_credential.credentials['multi_tenant_app']
|
|
end
|
|
end
|
|
|
|
raise Exceptions::UnprocessableContent, __("The required parameter 'client_id' is missing.") if credentials[:client_id].blank?
|
|
raise Exceptions::UnprocessableContent, __("The required parameter 'client_secret' is missing.") if credentials[:client_secret].blank? && !using_multi_tenant_app?(credentials)
|
|
|
|
if using_multi_tenant_app?(credentials)
|
|
code_verifier = generate_code_verifier
|
|
code_challenge = code_challenge_for(code_verifier)
|
|
end
|
|
|
|
state = generate_state(credentials)
|
|
authorize_url = generate_authorize_url(credentials, state: state, code_challenge: code_challenge)
|
|
|
|
{
|
|
authorize_url: authorize_url,
|
|
request_token: state,
|
|
code_verifier: code_verifier,
|
|
}.compact
|
|
end
|
|
|
|
def self.link_account(request_token, params)
|
|
# return to admin interface if admin Consent is in process and user clicks on "Back to app"
|
|
return "#{Setting.get('http_type')}://#{Setting.get('fqdn')}/#channels/#{provider_name}/error/AADSTS65004" if params[:error_description].present? && params[:error_description].include?('AADSTS65004')
|
|
|
|
raise Exceptions::UnprocessableContent, __('Invalid OAuth state parameter.') if params[:state] != request_token
|
|
|
|
external_credential = ExternalCredential.find_by(name: provider_name)
|
|
raise Exceptions::UnprocessableContent, error_missing_app_configuration if !external_credential
|
|
raise Exceptions::UnprocessableContent, __("The required parameter 'code' is missing.") if !params[:code]
|
|
|
|
response = authorize_tokens(external_credential.credentials, params[:code], code_verifier: params[:code_verifier])
|
|
%w[refresh_token access_token expires_in scope token_type id_token].each do |key|
|
|
raise Exceptions::UnprocessableContent, "No #{key} for authorization request found!" if response[key.to_sym].blank?
|
|
end
|
|
|
|
user_data = user_info(response[:id_token])
|
|
raise Exceptions::UnprocessableContent, __("The user's 'preferred_username' could not be extracted from 'id_token'.") if user_data[:preferred_username].blank?
|
|
|
|
account_data = {}
|
|
|
|
# Restore shared mailbox information from session and clean it up.
|
|
if params[:shared_mailbox].present?
|
|
account_data[:shared_mailbox] = params[:shared_mailbox]
|
|
end
|
|
|
|
channel_options = {
|
|
inbound: channel_options_inbound(user_data, account_data),
|
|
outbound: channel_options_outbound(user_data, account_data),
|
|
auth: response.merge(
|
|
{
|
|
provider: provider_name,
|
|
type: 'XOAUTH2',
|
|
client_id: external_credential.credentials[:client_id],
|
|
client_secret: external_credential.credentials[:client_secret],
|
|
client_tenant: external_credential.credentials[:client_tenant],
|
|
}.compact,
|
|
),
|
|
}
|
|
|
|
if params[:channel_id]
|
|
existing_channel = Channel.where(area: channel_area).find(params[:channel_id])
|
|
|
|
# Check if current user of the channel is matching the user from the token.
|
|
# Allow mismatch in case of a shared mailbox, since multiple users may be able access the same mailbox.
|
|
# In this case, inbound probe should verify if everything still works as expected.
|
|
token_user = user_data[:preferred_username]&.downcase
|
|
inbound_user = channel_user(existing_channel, :inbound)&.downcase
|
|
outbound_user = channel_user(existing_channel, :outbound)&.downcase
|
|
shared_mailbox = channel_shared_mailbox(existing_channel)
|
|
if ((inbound_user.present? && inbound_user != token_user) || (outbound_user.present? && outbound_user != token_user)) && shared_mailbox.blank?
|
|
return "#{Setting.get('http_type')}://#{Setting.get('fqdn')}/#channels/#{provider_name}/error/user_mismatch/channel/#{existing_channel.id}"
|
|
end
|
|
|
|
channel_options[:inbound][:options][:shared_mailbox] = shared_mailbox if shared_mailbox.present?
|
|
channel_options[:inbound][:options][:folder] = existing_channel.options[:inbound][:options][:folder]
|
|
channel_options[:inbound][:options][:keep_on_server] = existing_channel.options[:inbound][:options][:keep_on_server]
|
|
|
|
existing_channel.update!(
|
|
options: channel_options,
|
|
)
|
|
|
|
existing_channel.refresh_xoauth2!
|
|
|
|
return existing_channel
|
|
end
|
|
|
|
if channel_migration_possible?
|
|
migration_channel = find_migration_channel(user_data)
|
|
|
|
return execute_channel_migration(migrate_channel, channel_options) if migration_channel
|
|
end
|
|
|
|
email_address = {
|
|
name: "#{Setting.get('product_name')} Support",
|
|
email: account_data[:shared_mailbox] || user_data[:preferred_username],
|
|
}
|
|
|
|
existing_email_address = EmailAddress.where(email: email_address[:email])
|
|
|
|
# Check if a bound address with the same email already exists.
|
|
if existing_email_address.where.not(channel: nil).exists?
|
|
return "#{Setting.get('http_type')}://#{Setting.get('fqdn')}/#channels/#{provider_name}/error/duplicate_email_address/param/#{CGI.escapeURIComponent(email_address[:email])}"
|
|
end
|
|
|
|
# create channel
|
|
channel = Channel.create!(
|
|
area: channel_area,
|
|
group_id: Group.first.id,
|
|
options: channel_options,
|
|
active: false,
|
|
created_by_id: 1,
|
|
updated_by_id: 1,
|
|
)
|
|
|
|
# Assign an email address to the channel by either creating a new or repurposing an existing one.
|
|
if existing_email_address.exists?
|
|
existing_email_address.update!(
|
|
channel_id: channel.id,
|
|
name: email_address[:name],
|
|
email: email_address[:email],
|
|
active: true,
|
|
created_by_id: 1,
|
|
updated_by_id: 1,
|
|
)
|
|
else
|
|
EmailAddress.create_or_update(
|
|
channel_id: channel.id,
|
|
name: email_address[:name],
|
|
email: email_address[:email],
|
|
active: true,
|
|
created_by_id: 1,
|
|
updated_by_id: 1,
|
|
)
|
|
end
|
|
|
|
channel
|
|
end
|
|
|
|
def self.generate_state(_credentials)
|
|
SecureRandom.urlsafe_base64
|
|
end
|
|
|
|
def self.generate_authorize_url(credentials, scope: authorize_scope, state: nil, code_challenge: nil)
|
|
params = {
|
|
'client_id' => credentials[:client_id],
|
|
'redirect_uri' => redirect_uri(credentials),
|
|
'scope' => scope,
|
|
'response_type' => 'code',
|
|
'access_type' => 'offline',
|
|
'prompt' => credentials[:prompt] || 'login',
|
|
'state' => state,
|
|
'code_challenge' => code_challenge,
|
|
'code_challenge_method' => code_challenge && 'S256',
|
|
}.compact
|
|
|
|
tenant = credentials[:client_tenant].presence || 'common'
|
|
|
|
uri = URI::HTTPS.build(
|
|
host: 'login.microsoftonline.com',
|
|
path: "/#{tenant}/oauth2/v2.0/authorize",
|
|
query: params.to_query
|
|
)
|
|
|
|
uri.to_s
|
|
end
|
|
|
|
def self.authorize_tokens(credentials, authorization_code, code_verifier: nil)
|
|
uri = authorize_tokens_uri(credentials[:client_tenant])
|
|
params = authorize_tokens_params(credentials, authorization_code, code_verifier: code_verifier)
|
|
|
|
response = UserAgent.post(uri.to_s, params)
|
|
if response.code != 200 && response.body.blank?
|
|
Rails.logger.error "Request failed! (code: #{response.code})"
|
|
raise "Request failed! (code: #{response.code})"
|
|
end
|
|
|
|
result = JSON.parse(response.body)
|
|
if result['error'] && response.code != 200
|
|
Rails.logger.error "Request failed! ERROR: #{result['error']} (#{result['error_description']}, params: #{params.except(:client_secret, :code, :code_verifier).to_json})"
|
|
raise "Request failed! ERROR: #{result['error']} (#{result['error_description']})"
|
|
end
|
|
|
|
result[:created_at] = Time.zone.now
|
|
|
|
result.symbolize_keys
|
|
end
|
|
|
|
def self.authorize_tokens_params(credentials, authorization_code, code_verifier: nil)
|
|
{
|
|
client_secret: credentials[:client_secret],
|
|
code: authorization_code,
|
|
grant_type: 'authorization_code',
|
|
client_id: credentials[:client_id],
|
|
redirect_uri: redirect_uri(credentials),
|
|
code_verifier: code_verifier,
|
|
}.compact
|
|
end
|
|
|
|
def self.authorize_tokens_uri(tenant)
|
|
URI::HTTPS.build(
|
|
host: 'login.microsoftonline.com',
|
|
path: "/#{tenant.presence || 'common'}/oauth2/v2.0/token",
|
|
)
|
|
end
|
|
|
|
def self.refresh_token(token)
|
|
return token if token[:created_at] >= 50.minutes.ago
|
|
|
|
params = refresh_token_params(token)
|
|
uri = refresh_token_uri(token)
|
|
|
|
response = UserAgent.post(uri.to_s, params)
|
|
if response.code != 200 && response.body.blank?
|
|
Rails.logger.error "Request failed! (code: #{response.code})"
|
|
raise "Request failed! (code: #{response.code})"
|
|
end
|
|
|
|
result = JSON.parse(response.body)
|
|
if result['error'] && response.code != 200
|
|
Rails.logger.error "Request failed! ERROR: #{result['error']} (#{result['error_description']}, params: #{params.except(:client_secret, :refresh_token).to_json})"
|
|
raise "Request failed! ERROR: #{result['error']} (#{result['error_description']})"
|
|
end
|
|
|
|
token.merge(result.symbolize_keys).merge(
|
|
created_at: Time.zone.now,
|
|
)
|
|
end
|
|
|
|
def self.refresh_token_params(credentials)
|
|
{
|
|
client_id: credentials[:client_id],
|
|
client_secret: credentials[:client_secret],
|
|
refresh_token: credentials[:refresh_token],
|
|
grant_type: 'refresh_token',
|
|
}.compact
|
|
end
|
|
|
|
def self.refresh_token_uri(credentials)
|
|
tenant = credentials[:client_tenant].presence || 'common'
|
|
|
|
URI::HTTPS.build(
|
|
host: 'login.microsoftonline.com',
|
|
path: "/#{tenant}/oauth2/v2.0/token",
|
|
)
|
|
end
|
|
|
|
def self.channel_user(channel, key)
|
|
channel.options.dig(key.to_sym, :options, :user)
|
|
end
|
|
|
|
def self.channel_shared_mailbox(channel)
|
|
channel.options.dig(:inbound, :options, :shared_mailbox)
|
|
end
|
|
|
|
def self.user_info(id_token)
|
|
split = id_token.split('.')[1]
|
|
return if split.blank?
|
|
|
|
JSON.parse(Base64.decode64(split)).symbolize_keys
|
|
end
|
|
|
|
# The credential uses Zammad's shared multi-tenant Microsoft app registration
|
|
# while Zammad runs as an online service. In this mode the client_secret is
|
|
# held centrally (not per-instance) and PKCE is used to secure the
|
|
# authorization code exchange.
|
|
def self.using_multi_tenant_app?(credentials)
|
|
Setting.get('system_online_service') && credentials[:multi_tenant_app]
|
|
end
|
|
|
|
def self.generate_code_verifier
|
|
SecureRandom.urlsafe_base64(64)
|
|
end
|
|
|
|
def self.code_challenge_for(verifier)
|
|
Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
end
|
|
|
|
def self.redirect_uri(_credentials)
|
|
ExternalCredential.callback_url(provider_name)
|
|
end
|
|
end
|