saml_spec.rb 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. require 'rails_helper'
  3. require 'keycloak/admin'
  4. RSpec.describe 'SAML Authentication', authenticated_as: false, integration: true, integration_standalone: :saml, required_envs: %w[KEYCLOAK_BASE_URL KEYCLOAK_ADMIN_USER KEYCLOAK_ADMIN_PASSWORD], type: :system do
  5. let(:zammad_base_url) { "#{Capybara.app_host}:#{Capybara.current_session.server.port}" }
  6. let(:zammad_saml_metadata) { "#{zammad_base_url}/auth/saml/metadata" }
  7. let(:saml_base_url) { ENV['KEYCLOAK_BASE_URL'] }
  8. let(:saml_client_json) { Rails.root.join('test/data/saml/zammad-client.json').read.gsub('#ZAMMAD_BASE_URL', zammad_base_url) }
  9. let(:saml_realm_zammad_descriptor) { "#{saml_base_url}/realms/zammad/protocol/saml/descriptor" }
  10. let(:saml_realm_zammad_accounts) { "#{saml_base_url}/realms/zammad/account" }
  11. # Only before(:each) can access let() variables.
  12. before do
  13. # Setup Keycloak SAML authentication.
  14. if !Keycloak::Admin.configured?
  15. Keycloak::Admin.configure do |config|
  16. config.username = ENV['KEYCLOAK_ADMIN_USER']
  17. config.password = ENV['KEYCLOAK_ADMIN_PASSWORD']
  18. config.realm = 'zammad'
  19. config.base_url = ENV['KEYCLOAK_BASE_URL']
  20. end
  21. end
  22. # Force create Zammad client in Keycloak.
  23. client = Keycloak::Admin.clients.lookup(clientId: zammad_saml_metadata)
  24. if client.count.positive?
  25. Keycloak::Admin.clients.delete(client.first['id'])
  26. end
  27. Keycloak::Admin.clients.create(JSON.parse(saml_client_json))
  28. end
  29. def set_saml_config(name_identifier_format: nil, uid_attribute: nil, idp_slo_service_url: true, security: nil)
  30. # Setup Zammad SAML authentication.
  31. response = UserAgent.get(saml_realm_zammad_descriptor)
  32. raise 'No Zammad realm descriptor found' if !response.success?
  33. match = response.body.match(%r{<ds:X509Certificate>(?<cert>.+)</ds:X509Certificate>})
  34. raise 'No X509Certificate found' if !match[:cert]
  35. auth_saml_credentials =
  36. {
  37. display_name: 'SAML',
  38. idp_sso_target_url: "#{saml_base_url}/realms/zammad/protocol/saml",
  39. idp_cert: "-----BEGIN CERTIFICATE-----\n#{match[:cert]}\n-----END CERTIFICATE-----",
  40. name_identifier_format: name_identifier_format || 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
  41. }
  42. auth_saml_credentials[:idp_slo_service_url] = "#{saml_base_url}/realms/zammad/protocol/saml" if idp_slo_service_url
  43. auth_saml_credentials[:uid_attribute] = uid_attribute if uid_attribute
  44. if security.present?
  45. auth_saml_credentials[:security] = 'on'
  46. auth_saml_credentials[:certificate] = "-----BEGIN CERTIFICATE-----\n#{security[:cert]}\n-----END CERTIFICATE-----"
  47. auth_saml_credentials[:private_key] = "-----BEGIN RSA PRIVATE KEY-----\n#{security[:key]}\n-----END RSA PRIVATE KEY-----" # gitleaks:allow
  48. auth_saml_credentials[:private_key_secret] = ''
  49. end
  50. # Disable setting validation. We have an explicit test for this.
  51. setting = Setting.find_by(name: 'auth_saml_credentials')
  52. setting.update!(preferences: {})
  53. Setting.set('auth_saml_credentials', auth_saml_credentials)
  54. Setting.set('auth_saml', true)
  55. end
  56. # Shared_examples does not work.
  57. def login_saml(app: 'desktop')
  58. case app
  59. when 'desktop'
  60. visit '/#login'
  61. find('.auth-provider--saml').click
  62. when 'mobile'
  63. visit '/login', app: :mobile
  64. find('.icon-saml').click
  65. end
  66. login_saml_keycloak
  67. check_logged_in(app: app)
  68. end
  69. def login_saml_keycloak
  70. find_by_id('kc-form')
  71. expect(page).to have_current_path(%r{/realms/zammad/protocol/saml\?SAMLRequest=.+})
  72. expect(page).to have_css('#kc-form-login')
  73. within '#kc-form-login' do
  74. fill_in 'username', with: 'john.doe'
  75. fill_in 'password', with: 'test'
  76. click_on 'Sign In'
  77. end
  78. end
  79. def check_logged_in(app: 'desktop')
  80. find_by_id('app')
  81. case app
  82. when 'desktop'
  83. expect(page).to have_current_route('ticket/view/my_tickets')
  84. when 'mobile'
  85. # FIXME: Workaround because the redirect to the mobile app is not working due to a not set HTTP Referer in Capybara.
  86. visit '/', app: :mobile
  87. expect(page).to have_text('Home')
  88. end
  89. end
  90. def logout_saml
  91. await_empty_ajax_queue
  92. logout
  93. expect_current_route 'login'
  94. find_by_id('app')
  95. end
  96. # TODO: Should be replaced with tests for the new desktop-view (or the test in general should be removed outside of selenium).
  97. describe 'SP login and SP logout' do
  98. before do
  99. set_saml_config(security: security)
  100. end
  101. let(:security) { nil }
  102. it 'is successful' do
  103. login_saml
  104. visit saml_realm_zammad_accounts
  105. expect(page).to have_css('#landingSignOutButton')
  106. find_by_id('landingWelcomeMessage')
  107. logout_saml
  108. visit saml_realm_zammad_accounts
  109. expect(page).to have_no_css('#landingSignOutButton')
  110. find_by_id('landingWelcomeMessage')
  111. end
  112. context 'with client signature required and encrypted assertions enabled' do
  113. let(:security) do
  114. # generate a new private key and certificate
  115. key = OpenSSL::PKey::RSA.new(2048)
  116. cert = OpenSSL::X509::Certificate.new
  117. cert.subject = OpenSSL::X509::Name.parse('/CN=Zammad SAML Client')
  118. cert.issuer = cert.subject
  119. cert.not_before = Time.zone.now
  120. cert.not_after = (cert.not_before + (1 * 365 * 24 * 60 * 60)) # 1 year validity
  121. cert.public_key = key.public_key
  122. cert.serial = 0x0
  123. cert.version = 2
  124. ef = OpenSSL::X509::ExtensionFactory.new
  125. ef.subject_certificate = cert
  126. ef.issuer_certificate = cert
  127. cert.add_extension(ef.create_extension('keyUsage', 'digitalSignature, keyEncipherment', true))
  128. cert.add_extension(ef.create_extension('subjectKeyIdentifier', 'hash', false))
  129. cert.add_extension(ef.create_extension('basicConstraints', 'CA:FALSE', false))
  130. cert.sign(key, OpenSSL::Digest.new('SHA256'))
  131. pem = cert.to_pem
  132. pem.gsub!('-----BEGIN CERTIFICATE-----', '')
  133. pem.gsub!('-----END CERTIFICATE-----', '')
  134. pem.delete!("\n").strip!
  135. cert = pem
  136. pem = key.to_pem
  137. pem.gsub!('-----BEGIN RSA PRIVATE KEY-----', '')
  138. pem.gsub!('-----END RSA PRIVATE KEY-----', '')
  139. pem.delete!("\n").strip!
  140. key = pem
  141. {
  142. cert:,
  143. key:
  144. }
  145. end
  146. let(:saml_client_json) do
  147. client = Rails.root.join('test/data/saml/zammad-client-secure.json').read
  148. client.gsub!('#KEYCLOAK_ZAMMAD_BASE_URL', zammad_base_url)
  149. client.gsub!('#KEYCLOAK_ZAMMAD_CERTIFICATE', security[:cert])
  150. client
  151. end
  152. it 'is successful' do
  153. login_saml
  154. visit saml_realm_zammad_accounts
  155. expect(page).to have_css('#landingSignOutButton')
  156. find_by_id('landingWelcomeMessage')
  157. logout_saml
  158. visit saml_realm_zammad_accounts
  159. expect(page).to have_no_css('#landingSignOutButton')
  160. find_by_id('landingWelcomeMessage')
  161. end
  162. end
  163. end
  164. describe 'SP login and IDP logout' do
  165. before do
  166. set_saml_config
  167. end
  168. it 'is successful' do
  169. login_saml
  170. visit saml_realm_zammad_accounts
  171. find_by_id('landingWelcomeMessage')
  172. find('#landingSignOutButton').click
  173. visit '/'
  174. expect(page).to have_current_route('login')
  175. find_by_id('app')
  176. end
  177. end
  178. describe "use custom user attribute 'uid' as uid_attribute" do
  179. before do
  180. set_saml_config(uid_attribute: 'uid')
  181. end
  182. it 'is successful' do
  183. login_saml
  184. user = User.find_by(email: 'john.doe@saml.example.com')
  185. expect(user.login).to eq('5f8179df-db5e-415c-8090-6cc3634d86b6')
  186. logout_saml
  187. end
  188. end
  189. describe 'use unspecified (IDP provided) name identifier' do
  190. before do
  191. set_saml_config(name_identifier_format: 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified')
  192. end
  193. it 'is successful' do
  194. login_saml
  195. user = User.find_by(email: 'john.doe@saml.example.com')
  196. expect(user.login).to eq('john.doe')
  197. logout_saml
  198. end
  199. end
  200. describe 'SAML logout without IDP SLO service URL' do
  201. before do
  202. set_saml_config(idp_slo_service_url: false)
  203. end
  204. it 'is successful' do
  205. login_saml
  206. user = User.find_by(email: 'john.doe@saml.example.com')
  207. expect(user.login).to eq('john.doe@saml.example.com')
  208. logout_saml
  209. visit saml_realm_zammad_accounts
  210. expect(page).to have_css('#landingSignOutButton')
  211. end
  212. end
  213. describe 'Mobile View', app: :mobile do
  214. before do
  215. skip 'Skip mobile tests enforced.' if ENV['SKIP_MOBILE_TESTS']
  216. end
  217. context 'when login is tested' do
  218. before do
  219. set_saml_config
  220. end
  221. it 'is successful' do
  222. login_saml(app: 'mobile')
  223. visit saml_realm_zammad_accounts
  224. find('#landingMobileKebabButton').click
  225. expect(page).to have_css('#landingSignOutLink')
  226. end
  227. end
  228. context 'when logout is tested' do
  229. before do
  230. set_saml_config
  231. end
  232. it 'is successful' do
  233. login_saml(app: 'mobile')
  234. visit '/account', app: :mobile
  235. click_on('Sign out')
  236. wait.until do
  237. expect(page).to have_button('Sign in')
  238. end
  239. visit saml_realm_zammad_accounts
  240. find('#landingMobileKebabButton').click
  241. expect(page).to have_no_css('#landingSignOutLink')
  242. find_by_id('landingWelcomeMessage')
  243. end
  244. end
  245. context 'when saml user already exists with agent role' do
  246. before do
  247. Setting.set('auth_third_party_auto_link_at_inital_login', true)
  248. create(:agent, email: 'john.doe@saml.example.com', login: 'john.doe', firstname: 'John', lastname: 'Doe')
  249. set_saml_config
  250. end
  251. it 'is successful' do
  252. login_saml(app: 'mobile')
  253. visit saml_realm_zammad_accounts
  254. find('#landingMobileKebabButton').click
  255. expect(page).to have_css('#landingSignOutLink')
  256. end
  257. end
  258. context 'when logout is tested without IDP SLO service URL' do
  259. before do
  260. set_saml_config(idp_slo_service_url: false)
  261. end
  262. it 'is successful' do
  263. login_saml(app: 'mobile')
  264. visit '/account', app: :mobile
  265. click_on('Sign out')
  266. wait.until do
  267. expect(page).to have_button('Sign in')
  268. end
  269. visit saml_realm_zammad_accounts
  270. find('#landingMobileKebabButton').click
  271. expect(page).to have_css('#landingSignOutLink')
  272. end
  273. end
  274. end
  275. end