mirror of
https://github.com/zammad/zammad
synced 2026-05-24 09:48:36 +00:00
479 lines
18 KiB
Ruby
479 lines
18 KiB
Ruby
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
|
|
|
require 'rails_helper'
|
|
|
|
RSpec.describe ExternalCredential::MicrosoftGraph do
|
|
|
|
let(:token_url) { 'https://login.microsoftonline.com/common/oauth2/v2.0/token' }
|
|
let(:token_url_with_tenant) { 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token' }
|
|
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" }
|
|
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" }
|
|
|
|
let(:id_token) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtnMkxZczJUMENUaklmajRydDZKSXluZW4zOCJ9.eyJhdWQiOiIyMTk4NTFhYS0wMDAwLTRhNDctMTExMS0zMmQwNzAyZTAxMjM0IiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5taWNyb3NvZnRvbmxpbmUuY29tLzM2YTlhYjU1LWZpZmEtMjAyMC04YTc4LTkwcnM0NTRkYmNmZDJkL3YyLjAiLCJpYXQiOjEzMDE1NTE4MzUsIm5iZiI6MTMwMTU1MTgzNSwiZXhwIjoxNjAxNTU5NzQ0LCJuYW1lIjoiRXhhbXBsZSBVc2VyIiwib2lkIjoiMTExYWIyMTQtMTJzNy00M2NnLThiMTItM2ozM2UydDBjYXUyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoidGVzdEBleGFtcGxlLmNvbSIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInJoIjoiMC40MjM0LWZmZnNmZGdkaGRLZUpEU1hiejlMYXBSbUNHZGdmZ2RmZ0kwZHkwSEF1QlhaSEFNYy4iLCJzdWIiOiJYY0VlcmVyQkVnX0EzNWJlc2ZkczNMTElXNjU1NFQtUy0ycGRnZ2R1Z3c1NDNXT2xJIiwidGlkIjoiMzZhOWFiNTUtZmlmYS0yMDIwLThhNzgtOTByczQ1NGRiY2ZkMmQiLCJ1dGkiOiJEU0dGZ3Nhc2RkZmdqdGpyMzV3cWVlIiwidmVyIjoiMi4wIn0=.l0nglq4rIlkR29DFK3PQFQTjE-VeHdgLmcnXwGvT8Z-QBaQjeTAcoMrVpr0WdL6SRYiyn2YuqPnxey6N0IQdlmvTMBv0X_dng_y4CiQ8ABdZrQK0VSRWZViboJgW5iBvJYFcMmVoilHChueCzTBnS1Wp2KhirS2ymUkPHS6AB98K0tzOEYciR2eJsJ2JOdo-82oOW4w6tbbqMvzT3DzsxqPQRGe2hUbNqo6gcwJLqq4t0bNf5XiYThw1sv4IivERmqW_pfybXEseKyZGd4NnJ6WwwOgTz5tkoLwls_YeDZVcp_Fpw9XR7J0UlyPqLtoUEjVihdyrJjAbdtHFKdOjrw' }
|
|
let(:access_token) { '000.0000lvC3gAbjs8CYoKitfqM5LBS5N13374MCg6pNpZ28mxO2HuZvg0000_rsW00aACmFEto1BJeGDuu0000vmV6Esqv78iec-FbEe842ZevQtOOemQyQXjhMs62K1E6g3ehDLPRp6j4vtpSKSb6I-3MuDPfdzdqI23hM0' }
|
|
let(:refresh_token) { '1//00000VO1ES0hFCgYIARAAGAkSNwF-L9IraWQNMj5ZTqhB00006DssAYcpEyFks5OuvZ1337wrqX0D7tE5o71FIPzcWEMM5000004' }
|
|
let(:request_token) { 'test_oauth_state' }
|
|
|
|
let(:scope_payload) { 'offline_access openid user.readbasic.all mail.readwrite mail.readwrite.shared mail.send mail.send.shared' }
|
|
let(:scope_stub) { scope_payload }
|
|
|
|
let(:client_id) { '123' }
|
|
let(:client_secret) { '345' }
|
|
let(:client_tenant) { 'tenant' }
|
|
let(:authorization_code) { '567' }
|
|
|
|
let(:email_address) { 'test@example.com' }
|
|
let(:provider) { 'microsoft_graph' }
|
|
let(:token_ttl) { 3599 }
|
|
|
|
let!(:token_response_payload) do
|
|
{
|
|
'access_token' => access_token,
|
|
'expires_in' => token_ttl,
|
|
'refresh_token' => refresh_token,
|
|
'scope' => scope_stub,
|
|
'token_type' => 'Bearer',
|
|
'id_token' => id_token,
|
|
'type' => 'XOAUTH2',
|
|
}
|
|
end
|
|
|
|
describe '.link_account' do
|
|
let!(:authorization_payload) do
|
|
{
|
|
code: authorization_code,
|
|
scope: scope_payload,
|
|
state: request_token,
|
|
authuser: '4',
|
|
hd: 'example.com',
|
|
prompt: 'consent',
|
|
controller: 'external_credentials',
|
|
action: 'callback',
|
|
provider: provider
|
|
}
|
|
end
|
|
|
|
before do
|
|
# we check the TTL of tokens and therefore need freeze the time
|
|
freeze_time
|
|
end
|
|
|
|
context 'when success' do
|
|
|
|
let(:request_payload) do
|
|
{
|
|
'client_secret' => client_secret,
|
|
'code' => authorization_code,
|
|
'grant_type' => 'authorization_code',
|
|
'client_id' => client_id,
|
|
'redirect_uri' => ExternalCredential.callback_url(provider),
|
|
}
|
|
end
|
|
|
|
before do
|
|
stub_request(:post, token_url)
|
|
.with(body: hash_including(request_payload))
|
|
.to_return(status: 200, body: token_response_payload.to_json, headers: {})
|
|
|
|
create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret })
|
|
end
|
|
|
|
it 'creates a Channel instance', :aggregate_failures do
|
|
channel = described_class.link_account(request_token, authorization_payload)
|
|
|
|
expect(channel.options).to include(
|
|
'inbound' => {
|
|
adapter: 'microsoft_graph_inbound',
|
|
options: {
|
|
'user' => email_address,
|
|
}
|
|
},
|
|
'outbound' => {
|
|
adapter: 'microsoft_graph_outbound',
|
|
options: {
|
|
'user' => email_address,
|
|
}
|
|
},
|
|
'auth' => include(
|
|
'access_token' => access_token,
|
|
'expires_in' => token_ttl,
|
|
'refresh_token' => refresh_token,
|
|
'scope' => scope_stub,
|
|
'token_type' => 'Bearer',
|
|
'id_token' => id_token,
|
|
'created_at' => Time.zone.now,
|
|
'type' => 'XOAUTH2',
|
|
'client_id' => client_id,
|
|
'client_secret' => client_secret,
|
|
),
|
|
)
|
|
|
|
channel.options[:inbound][:options][:keep_on_server] = true
|
|
channel.save
|
|
|
|
channel = described_class.link_account(request_token, authorization_payload.merge(channel_id: channel.id))
|
|
expect(channel.reload.options[:inbound][:options][:keep_on_server]).to be(true)
|
|
end
|
|
|
|
context 'when users do not match', :aggregate_failures do
|
|
let(:existing_channel) do
|
|
ENV['MICROSOFT365_USER'] = 'zammad@outlook.com'
|
|
ENV['MICROSOFT365_CLIENT_ID'] = 'xxx'
|
|
ENV['MICROSOFT365_CLIENT_SECRET'] = 'xxx'
|
|
ENV['MICROSOFT365_CLIENT_TENANT'] = 'xxx'
|
|
|
|
create(:microsoft_graph_channel)
|
|
end
|
|
|
|
it 'generates a link to an error dialog & does not update the channel' do
|
|
link_account_response = described_class.link_account(request_token, authorization_payload.merge(channel_id: existing_channel.id))
|
|
|
|
expect(link_account_response).to eq("#{Setting.get('http_type')}://#{Setting.get('fqdn')}/#channels/#{provider}/error/user_mismatch/channel/#{existing_channel.id}")
|
|
|
|
expect(existing_channel.reload.options.dig(:inbound, :options, :user)).to eq('zammad@outlook.com')
|
|
expect(existing_channel.reload.options.dig(:outbound, :options, :user)).to eq('zammad@outlook.com')
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when running as an online service with a multi_tenant_app credential (PKCE)' do
|
|
let(:code_verifier) { 'test_code_verifier' }
|
|
|
|
let(:request_payload) do
|
|
{
|
|
'client_secret' => client_secret,
|
|
'code' => authorization_code,
|
|
'grant_type' => 'authorization_code',
|
|
'client_id' => client_id,
|
|
'redirect_uri' => ExternalCredential.callback_url(provider),
|
|
'code_verifier' => code_verifier,
|
|
}
|
|
end
|
|
|
|
before do
|
|
Setting.set('system_online_service', true)
|
|
|
|
stub_request(:post, token_url)
|
|
.with(body: hash_including(request_payload))
|
|
.to_return(status: 200, body: token_response_payload.to_json, headers: {})
|
|
|
|
create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret, multi_tenant_app: true })
|
|
end
|
|
|
|
it 'forwards code_verifier to the token request' do
|
|
channel = described_class.link_account(request_token, authorization_payload.merge(code_verifier: code_verifier))
|
|
expect(channel).to be_a(Channel)
|
|
end
|
|
end
|
|
|
|
context 'when OAuth state is invalid' do
|
|
it 'raises an error' do
|
|
expect do
|
|
described_class.link_account('wrong_state', authorization_payload)
|
|
end.to raise_error(Exceptions::UnprocessableContent, 'Invalid OAuth state parameter.')
|
|
end
|
|
end
|
|
|
|
context 'when API errors' do
|
|
before do
|
|
stub_request(:post, token_url).to_return(status: response_status, body: response_payload&.to_json, headers: {})
|
|
|
|
create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret })
|
|
end
|
|
|
|
shared_examples 'failed attempt' do
|
|
it 'raises an exception' do
|
|
expect do
|
|
described_class.link_account(request_token, authorization_payload)
|
|
end.to raise_error(RuntimeError, exception_message)
|
|
end
|
|
end
|
|
|
|
context 'when 404 invalid_client' do
|
|
let(:response_status) { 404 }
|
|
let(:response_payload) do
|
|
{
|
|
error: 'invalid_client',
|
|
error_description: 'The OAuth client was not found.'
|
|
}
|
|
end
|
|
let(:exception_message) { 'Request failed! ERROR: invalid_client (The OAuth client was not found.)' }
|
|
|
|
include_examples 'failed attempt'
|
|
end
|
|
|
|
context 'when 500 Internal Server Error' do
|
|
let(:response_status) { 500 }
|
|
let(:response_payload) { nil }
|
|
let(:exception_message) { 'Request failed! (code: 500)' }
|
|
|
|
include_examples 'failed attempt'
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '.refresh_token' do
|
|
let!(:authorization_payload) do
|
|
{
|
|
code: authorization_code,
|
|
scope: scope_payload,
|
|
state: request_token,
|
|
authuser: '4',
|
|
hd: 'example.com',
|
|
prompt: 'consent',
|
|
controller: 'external_credentials',
|
|
action: 'callback',
|
|
provider: provider
|
|
}
|
|
end
|
|
let!(:channel) do
|
|
stub_request(:post, token_url).to_return(status: 200, body: token_response_payload.to_json, headers: {})
|
|
|
|
create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret })
|
|
channel = described_class.link_account(request_token, authorization_payload)
|
|
|
|
# remove stubs and allow new stubbing for tested requests
|
|
WebMock.reset!
|
|
|
|
channel
|
|
end
|
|
|
|
before do
|
|
# we check the TTL of tokens and therefore need freeze the time
|
|
freeze_time
|
|
end
|
|
|
|
context 'when success' do
|
|
before do
|
|
stub_request(:post, token_url).to_return(status: 200, body: response_payload.to_json, headers: {})
|
|
end
|
|
|
|
context 'when access_token is still valid' do
|
|
let(:response_payload) do
|
|
{
|
|
'access_token' => access_token,
|
|
'expires_in' => token_ttl,
|
|
'scope' => scope_stub,
|
|
'token_type' => 'Bearer',
|
|
'type' => 'XOAUTH2',
|
|
}
|
|
end
|
|
|
|
it 'does not refresh' do
|
|
expect do
|
|
channel.refresh_xoauth2!
|
|
end.not_to change { channel.options['auth']['created_at'] }
|
|
end
|
|
end
|
|
|
|
context 'when access_token is expired' do
|
|
let(:refreshed_access_token) { 'some_new_token' }
|
|
|
|
let(:response_payload) do
|
|
{
|
|
'access_token' => refreshed_access_token,
|
|
'expires_in' => token_ttl,
|
|
'scope' => scope_stub,
|
|
'token_type' => 'Bearer',
|
|
'type' => 'XOAUTH2',
|
|
}
|
|
end
|
|
|
|
before do
|
|
travel 1.hour
|
|
end
|
|
|
|
it 'refreshes token' do
|
|
expect do
|
|
channel.refresh_xoauth2!
|
|
end.to change { channel.options['auth'] }.to include(
|
|
'created_at' => Time.zone.now,
|
|
'access_token' => refreshed_access_token,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when API errors' do
|
|
|
|
before do
|
|
stub_request(:post, token_url).to_return(status: response_status, body: response_payload&.to_json, headers: {})
|
|
|
|
# invalidate existing token
|
|
travel 1.hour
|
|
end
|
|
|
|
shared_examples 'failed attempt' do
|
|
it 'raises an exception' do
|
|
expect do
|
|
channel.refresh_xoauth2!
|
|
end.to raise_error(RuntimeError, exception_message)
|
|
end
|
|
end
|
|
|
|
context 'when 400 invalid_client' do
|
|
let(:response_status) { 400 }
|
|
let(:response_payload) do
|
|
{
|
|
error: 'invalid_client',
|
|
error_description: 'The OAuth client was not found.'
|
|
}
|
|
end
|
|
let(:exception_message) { %r{The OAuth client was not found} }
|
|
|
|
include_examples 'failed attempt'
|
|
end
|
|
|
|
context 'when 500 Internal Server Error' do
|
|
let(:response_status) { 500 }
|
|
let(:response_payload) { nil }
|
|
let(:exception_message) { %r{code: 500} }
|
|
|
|
include_examples 'failed attempt'
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '.request_account_to_link' do
|
|
let(:state) { 'test_oauth_state' }
|
|
|
|
before { allow(SecureRandom).to receive(:urlsafe_base64).and_return(state) }
|
|
|
|
it 'generates authorize_url and state from credentials', :aggregate_failures do
|
|
microsoft_graph = create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret })
|
|
request = described_class.request_account_to_link(microsoft_graph.credentials)
|
|
|
|
expect(request[:authorize_url]).to eq("#{authorize_url}&state=#{state}")
|
|
expect(request[:request_token]).to eq(state)
|
|
expect(request).not_to have_key(:code_verifier)
|
|
end
|
|
|
|
context 'when running as an online service with a multi_tenant_app credential (PKCE)' do
|
|
let(:verifier) { 'test_code_verifier' }
|
|
let(:expected_challenge) { Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) }
|
|
let(:authorize_url_pkce) { "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?access_type=offline&client_id=#{client_id}&code_challenge=#{expected_challenge}&code_challenge_method=S256&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" }
|
|
|
|
before do
|
|
Setting.set('system_online_service', true)
|
|
allow(described_class).to receive(:generate_code_verifier).and_return(verifier)
|
|
end
|
|
|
|
it 'returns code_verifier and adds code_challenge with S256 method to the URL', :aggregate_failures do
|
|
microsoft_graph = create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret, multi_tenant_app: true })
|
|
request = described_class.request_account_to_link(microsoft_graph.credentials)
|
|
|
|
expect(request[:authorize_url]).to eq("#{authorize_url_pkce}&state=#{state}")
|
|
expect(request[:request_token]).to eq(state)
|
|
expect(request[:code_verifier]).to eq(verifier)
|
|
end
|
|
|
|
it 'does not enable PKCE when multi_tenant_app is false' do
|
|
microsoft_graph = create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret, multi_tenant_app: false })
|
|
request = described_class.request_account_to_link(microsoft_graph.credentials)
|
|
|
|
expect(request).not_to have_key(:code_verifier)
|
|
end
|
|
end
|
|
|
|
context 'when errors' do
|
|
|
|
shared_examples 'failed attempt' do
|
|
it 'raises an exception' do
|
|
expect do
|
|
described_class.request_account_to_link(credentials, app_required)
|
|
end.to raise_error(Exceptions::UnprocessableContent, exception_message)
|
|
end
|
|
end
|
|
|
|
context 'when missing credentials' do
|
|
let(:credentials) { nil }
|
|
let(:app_required) { true }
|
|
let(:exception_message) { 'No Microsoft Graph app configured!' }
|
|
|
|
include_examples 'failed attempt'
|
|
end
|
|
|
|
context 'when missing client_id' do
|
|
let(:credentials) do
|
|
{
|
|
client_secret: client_secret
|
|
}
|
|
end
|
|
let(:app_required) { false }
|
|
let(:exception_message) { "The required parameter 'client_id' is missing." }
|
|
|
|
include_examples 'failed attempt'
|
|
end
|
|
|
|
context 'when missing client_secret' do
|
|
let(:credentials) do
|
|
{
|
|
client_id: client_id
|
|
}
|
|
end
|
|
let(:app_required) { false }
|
|
let(:exception_message) { "The required parameter 'client_secret' is missing." }
|
|
|
|
include_examples 'failed attempt'
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '.generate_authorize_url' do
|
|
let(:state) { 'test_oauth_state' }
|
|
|
|
it 'generates valid URL' do
|
|
url = described_class.generate_authorize_url({ client_id: client_id }, state: state)
|
|
expect(url).to eq("#{authorize_url}&state=#{state}")
|
|
end
|
|
|
|
it 'generates valid URL with tenant' do
|
|
url = described_class.generate_authorize_url({ client_id: client_id, client_tenant: 'tenant' }, state: state)
|
|
expect(url).to eq("#{authorize_url_with_tenant}&state=#{state}")
|
|
end
|
|
end
|
|
|
|
describe '.user_info' do
|
|
it 'extracts user information from id_token' do
|
|
info = described_class.user_info(id_token)
|
|
expect(info[:email]).to eq(email_address)
|
|
end
|
|
end
|
|
|
|
describe '.update_client_secret' do
|
|
let(:channel) do
|
|
ENV['MICROSOFT365_USER'] = 'zammad@outlook.com'
|
|
ENV['MICROSOFT365_CLIENT_ID'] = 'id1337'
|
|
ENV['MICROSOFT365_CLIENT_SECRET'] = 'dummy'
|
|
ENV['MICROSOFT365_CLIENT_TENANT'] = 'xxx'
|
|
|
|
create(:microsoft_graph_channel)
|
|
end
|
|
|
|
let(:external_credential) { create(:external_credential, name: provider, credentials: { client_id: 'id1337', client_secret: 'dummy' }) }
|
|
|
|
context 'when client_secret was updated' do
|
|
context 'when secret is different' do
|
|
before do
|
|
channel.options[:auth][:client_secret] = 'dummy-other'
|
|
channel.save!
|
|
end
|
|
|
|
it 'does not update the channel' do
|
|
external_credential.update!(credentials: { client_id: 'id1337', client_secret: 'dummy-new' })
|
|
|
|
expect(channel.reload.options[:auth][:client_secret]).to eq('dummy-other')
|
|
end
|
|
end
|
|
|
|
context 'when secret is the same' do
|
|
it 'updates the setting' do
|
|
channel
|
|
external_credential.update!(credentials: { client_id: 'id1337', client_secret: 'dummy-new' })
|
|
|
|
expect(channel.reload.options[:auth][:client_secret]).to eq('dummy-new')
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|