diff --git a/modules/auth_saml/app/components/saml/providers/metadata_changes/row_component.rb b/modules/auth_saml/app/components/saml/providers/metadata_changes/row_component.rb
new file mode 100644
index 000000000000..36013c1b9867
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/metadata_changes/row_component.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module Saml
+ module Providers
+ module MetadataChanges
+ class RowComponent < ::OpPrimer::BorderBoxRowComponent
+ delegate :provider, to: :table
+
+ def attribute
+ I18n.t("activerecord.attributes.saml/provider.#{key}")
+ end
+
+ def current_value
+ current = provider.options_in_database[key]
+ return I18n.t(:label_none_parentheses) if current.blank?
+
+ simple_format(h(current.to_s))
+ end
+
+ def new_value
+ simple_format(h(value.to_s))
+ end
+
+ private
+
+ def key = row.first
+
+ def value = row.last
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/components/saml/providers/metadata_changes/table_component.rb b/modules/auth_saml/app/components/saml/providers/metadata_changes/table_component.rb
new file mode 100644
index 000000000000..f795510e7ff5
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/metadata_changes/table_component.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module Saml
+ module Providers
+ module MetadataChanges
+ class TableComponent < ::OpPrimer::BorderBoxTableComponent
+ options :provider
+
+ columns :attribute, :current_value, :new_value
+ main_column :attribute, :current_value, :new_value
+ mobile_labels :current_value, :new_value
+
+ def row_class
+ RowComponent
+ end
+
+ def mobile_title
+ t(".mobile_title")
+ end
+
+ def headers
+ [
+ [:attribute, { caption: I18n.t(:label_attribute) }],
+ [:current_value, { caption: t(".current_value") }],
+ [:new_value, { caption: t(".new_value") }]
+ ]
+ end
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/components/saml/providers/side_panel/refresh_metadata_component.html.erb b/modules/auth_saml/app/components/saml/providers/side_panel/refresh_metadata_component.html.erb
new file mode 100644
index 000000000000..7054fc93a315
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/side_panel/refresh_metadata_component.html.erb
@@ -0,0 +1,14 @@
+<%=
+ render(Primer::OpenProject::SidePanel::Section.new(test_selector: "saml_provider_refresh_metadata")) do |section|
+ section.with_title { I18n.t("saml.providers.label_metadata") }
+ section.with_description { t(".description") }
+
+ render(
+ Primer::Beta::Button.new(
+ tag: :a,
+ href: refresh_metadata_saml_provider_path(provider),
+ data: { turbo_prefetch: false }
+ )
+ ) { t(".button") }
+ end
+%>
diff --git a/modules/auth_saml/app/components/saml/providers/side_panel/refresh_metadata_component.rb b/modules/auth_saml/app/components/saml/providers/side_panel/refresh_metadata_component.rb
new file mode 100644
index 000000000000..07bdcff57dbb
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/side_panel/refresh_metadata_component.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module Saml::Providers
+ module SidePanel
+ class RefreshMetadataComponent < ApplicationComponent
+ include ApplicationHelper
+ include OpTurbo::Streamable
+ include OpPrimer::ComponentHelpers
+
+ alias_method :provider, :model
+
+ def render?
+ provider.persisted? && provider.metadata_url.present? && !provider.seeded_from_env?
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb b/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb
index aff99061d6a4..7f9af5d435c2 100644
--- a/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb
+++ b/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb
@@ -3,7 +3,8 @@
render(Primer::OpenProject::SidePanel.new(spacious: true)) do |panel|
[
Saml::Providers::SidePanel::MetadataComponent.new(@provider),
- Saml::Providers::SidePanel::InformationComponent.new(@provider)
+ Saml::Providers::SidePanel::InformationComponent.new(@provider),
+ Saml::Providers::SidePanel::RefreshMetadataComponent.new(@provider)
].each do |component|
panel.with_section(component)
end
diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb
index cf1a8b61de86..bbec77323588 100644
--- a/modules/auth_saml/app/controllers/saml/providers_controller.rb
+++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb
@@ -36,8 +36,10 @@ class ProvidersController < ::ApplicationController
before_action :require_admin
before_action :check_ee, except: %i[index]
- before_action :find_provider, only: %i[show edit import_metadata update confirm_destroy destroy]
- before_action :check_provider_writable, only: %i[update import_metadata]
+ before_action :find_provider,
+ only: %i[show edit import_metadata refresh_metadata apply_metadata update confirm_destroy destroy]
+ before_action :check_provider_writable, only: %i[update import_metadata refresh_metadata apply_metadata]
+ before_action :check_metadata_url, only: %i[refresh_metadata apply_metadata]
before_action :set_edit_state, only: %i[create edit update import_metadata]
def index
@@ -97,6 +99,29 @@ def create
end
end
+ def refresh_metadata
+ call = Saml::UpdateMetadataService.new(provider: @provider, user: User.current).call
+
+ if call.success?
+ prepare_metadata_preview
+ else
+ flash[:error] = call.message
+ redirect_to saml_provider_path(@provider)
+ end
+ end
+
+ def apply_metadata
+ preview = metadata_verifier.verified(params[:metadata_token], purpose: metadata_token_purpose)
+
+ if (error = metadata_preview_error(preview))
+ flash[:error] = error
+ else
+ apply_metadata_options(preview["options"])
+ end
+
+ redirect_to saml_provider_path(@provider)
+ end
+
def update
call = Saml::Providers::UpdateService
.new(model: @provider, user: User.current)
@@ -131,6 +156,50 @@ def destroy
private
+ def prepare_metadata_preview
+ @metadata_changes = @provider.options.filter do |key, value|
+ key != "last_metadata_update" && value != @provider.options_in_database[key]
+ end
+ @metadata_token = metadata_verifier.generate(
+ {
+ "provider_version" => @provider.cache_key_with_version,
+ "options" => @metadata_changes.merge("last_metadata_update" => @provider.last_metadata_update)
+ },
+ purpose: metadata_token_purpose,
+ expires_in: 15.minutes
+ )
+ end
+
+ def metadata_preview_error(preview)
+ if preview.nil?
+ t(".expired")
+ elsif preview["provider_version"] != @provider.cache_key_with_version
+ t(".provider_changed")
+ end
+ end
+
+ def apply_metadata_options(options)
+ call = Saml::Providers::UpdateService.new(model: @provider, user: User.current).call(options:)
+
+ if call.success?
+ flash[:notice] = I18n.t("saml.metadata_parser.success")
+ else
+ flash[:error] = call.message
+ end
+ end
+
+ def check_metadata_url
+ render_404 if @provider.metadata_url.blank?
+ end
+
+ def metadata_verifier
+ Rails.application.message_verifier("saml_metadata_refresh")
+ end
+
+ def metadata_token_purpose
+ "apply_metadata/#{@provider.id}"
+ end
+
def successful_save_response
if @new_mode && !@next_edit_state
flash[:notice] = I18n.t("saml.providers.notice_created")
diff --git a/modules/auth_saml/app/services/saml/update_metadata_service.rb b/modules/auth_saml/app/services/saml/update_metadata_service.rb
index fcd2c17c3965..babab556ace8 100644
--- a/modules/auth_saml/app/services/saml/update_metadata_service.rb
+++ b/modules/auth_saml/app/services/saml/update_metadata_service.rb
@@ -55,12 +55,11 @@ def call # rubocop:disable Metrics/AbcSize
private
def apply_metadata(metadata)
- new_options = provider.options.merge(metadata)
last_metadata_update = metadata.blank? ? nil : Time.current
Saml::Providers::SetAttributesService
.new(model: @provider, user: User.current, contract_class: Saml::Providers::UpdateContract)
- .call({ options: new_options, last_metadata_update: })
+ .call({ options: metadata, last_metadata_update: })
end
def fetch_metadata
diff --git a/modules/auth_saml/app/views/saml/providers/refresh_metadata.html.erb b/modules/auth_saml/app/views/saml/providers/refresh_metadata.html.erb
new file mode 100644
index 000000000000..6ccc45741c0b
--- /dev/null
+++ b/modules/auth_saml/app/views/saml/providers/refresh_metadata.html.erb
@@ -0,0 +1,34 @@
+<% html_title(t(:label_administration), t(".title"), @provider.display_name) %>
+
+<% content_for :header_tags do %>
+
+<% end %>
+
+<%= render(Primer::OpenProject::PageHeader.new) do |header| %>
+ <% header.with_title { t(".title") } %>
+ <% header.with_breadcrumbs(
+ [{ href: admin_index_path, text: t(:label_administration) },
+ { href: saml_providers_path, text: t("saml.providers.plural") },
+ { href: saml_provider_path(@provider), text: @provider.display_name },
+ t(".title")]
+ ) %>
+ <% header.with_description { t(".description") } %>
+<% end %>
+
+<% if @metadata_changes.empty? %>
+ <%= render(Primer::Alpha::Banner.new) { t(".no_changes") } %>
+<% else %>
+ <%= render(Saml::Providers::MetadataChanges::TableComponent.new(rows: @metadata_changes.to_a, provider: @provider)) %>
+<% end %>
+
+<%= form_with(url: apply_metadata_saml_provider_path(@provider), method: :post) do %>
+ <%= hidden_field_tag :metadata_token, @metadata_token %>
+ <%= flex_layout(align_items: :center, mt: 3) do |actions|
+ actions.with_column(mr: 2) do
+ render(Primer::Beta::Button.new(type: :submit, scheme: :primary)) { t(".apply") }
+ end
+ actions.with_column do
+ render(Primer::Beta::Button.new(tag: :a, href: saml_provider_path(@provider))) { t(:button_cancel) }
+ end
+ end %>
+<% end %>
diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml
index 3af301e1fadf..1d220f892ed7 100644
--- a/modules/auth_saml/config/locales/en.yml
+++ b/modules/auth_saml/config/locales/en.yml
@@ -13,6 +13,7 @@ en:
icon: "Custom icon"
identifier: Identifier
idp_cert: Public certificate of identity provider
+ idp_cert_fingerprint: Identity provider certificate fingerprint
idp_entity_id: Identity provider entity ID
idp_slo_service_url: Identity provider logout endpoint
idp_sso_service_url: Identity provider login endpoint
@@ -128,6 +129,9 @@ en:
metadata_too_large: "The metadata file exceeds the maximum allowed size."
success: "Successfully updated the configuration using the identity provider metadata."
providers:
+ apply_metadata:
+ expired: "This metadata preview has expired or is invalid. Refresh the metadata again to review the latest configuration."
+ provider_changed: "The provider configuration has changed since this preview. Refresh the metadata again to review the latest configuration."
attribute_mapping: Attribute mapping
attribute_mapping_text: >
The following fields control which attributes provided by the SAML identity provider
@@ -147,10 +151,20 @@ en:
label_uid: Internal user id
metadata:
dialog: "This is the URL where the OpenProject SAML metadata is available. Optionally use it to configure your identity provider:"
+ metadata_changes:
+ table_component:
+ current_value: "Current value"
+ mobile_title: "Metadata changes"
+ new_value: "New value"
no_results_table: No SAML identity providers have been defined yet.
notice_created: A new SAML identity provider was successfully created.
plural: SAML identity providers
+ refresh_metadata:
+ apply: "Apply changes"
+ description: "Review the configuration fetched from your identity provider. Changes are saved only when you apply them."
+ no_changes: "The metadata matches the current configuration."
+ title: "Review metadata changes"
request_attributes:
format: 'Attribute format'
legend: >
@@ -174,6 +188,10 @@ en:
metadata_form_banner: "Editing the metadata may override existing values in other sections. "
requested_attributes: "Define the set of attributes to be requested in the SAML request sent to your identity provider."
seeded_from_env: "This provider was seeded from the environment configuration. It cannot be edited."
+ side_panel:
+ refresh_metadata_component:
+ button: "Refresh metadata"
+ description: "You can check if the configuration fetched from your identity provider has changed."
singular: SAML identity provider
upsell:
description: Connect OpenProject to a SAML identity provider
diff --git a/modules/auth_saml/config/routes.rb b/modules/auth_saml/config/routes.rb
index 133ea54d6eca..149a10305933 100644
--- a/modules/auth_saml/config/routes.rb
+++ b/modules/auth_saml/config/routes.rb
@@ -33,6 +33,8 @@
resources :providers do
member do
post :import_metadata
+ get :refresh_metadata
+ post :apply_metadata
get :confirm_destroy
end
end
diff --git a/modules/auth_saml/spec/features/administration/saml_crud_spec.rb b/modules/auth_saml/spec/features/administration/saml_crud_spec.rb
index 5151987d3a3b..0f8f2ef7074f 100644
--- a/modules/auth_saml/spec/features/administration/saml_crud_spec.rb
+++ b/modules/auth_saml/spec/features/administration/saml_crud_spec.rb
@@ -186,6 +186,37 @@
provider.reload
expect(provider.limit_self_registration).to be true
end
+
+ it "can review, cancel, and apply refreshed metadata", :webmock do
+ url = "https://example.com/metadata"
+ provider.update!(metadata_url: url, last_metadata_update: 1.day.ago)
+ original_options = provider.options.deep_dup
+ metadata = Rails.root.join("modules/auth_saml/spec/fixtures/idp_metadata.xml").read
+ stub_request(:get, url).to_return(status: 200, body: metadata)
+
+ visit saml_provider_path(provider)
+ within_test_selector("saml_provider_refresh_metadata") do
+ expect(page).to have_text("You can check if the configuration fetched from your identity provider has changed.")
+ click_on "Refresh metadata"
+ end
+
+ expect(page).to have_heading(text: "Review metadata changes")
+ expect(page).to have_text("https://example.com/sso")
+ expect(page).to have_text("https://example.com/login")
+ expect(provider.reload.options).to eq(original_options)
+
+ click_on "Cancel"
+ expect(page).to have_heading(text: "My provider")
+ expect(provider.reload.options).to eq(original_options)
+
+ click_on "Refresh metadata"
+ click_on "Apply changes"
+
+ expect(page).to have_text("Successfully updated the configuration using the identity provider metadata.")
+ expect(page).to have_heading(text: "My provider")
+ expect(provider.reload.idp_sso_service_url).to eq("https://example.com/login")
+ expect(provider.last_metadata_update).to be > 1.minute.ago
+ end
end
end
diff --git a/modules/auth_saml/spec/requests/saml_metadata_refresh_spec.rb b/modules/auth_saml/spec/requests/saml_metadata_refresh_spec.rb
new file mode 100644
index 000000000000..29ccf3027053
--- /dev/null
+++ b/modules/auth_saml/spec/requests/saml_metadata_refresh_spec.rb
@@ -0,0 +1,252 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+require_module_spec_helper
+
+RSpec.describe "SAML metadata refresh", :skip_csrf, :webmock,
+ type: :rails_request, with_ee: %i[sso_auth_providers] do
+ let(:admin) { create(:admin) }
+ let(:metadata_url) { "https://example.com/metadata" }
+ let(:metadata) { Rails.root.join("modules/auth_saml/spec/fixtures/idp_metadata.xml").read }
+ let(:provider) { create(:saml_provider, :with_encryption, metadata_url:, last_metadata_update: 1.day.ago) }
+ let(:metadata_token) { page.find("input[name='metadata_token']", visible: :all).value }
+
+ before do
+ login_as admin
+ allow(OpenProject::SsrfProtection).to receive(:safe_ip?).with("example.com").and_return(IPAddr.new("93.184.216.34"))
+ stub_request(:get, metadata_url).to_return(status: 200, body: metadata)
+ end
+
+ it "offers a refresh action in the side panel" do
+ get saml_provider_path(provider)
+
+ expect(page.find(:test_id, "saml_provider_refresh_metadata"))
+ .to have_link("Refresh metadata", href: refresh_metadata_saml_provider_path(provider))
+ expect(page.find(:test_id, "saml_provider_metadata")).to have_no_link("Refresh metadata")
+ end
+
+ it "previews the fetched changes without saving them" do
+ original_options = provider.options.deep_dup
+
+ get refresh_metadata_saml_provider_path(provider)
+
+ expect(response).to have_http_status(:ok)
+ expect(page).to have_heading("Review metadata changes")
+ expect(page).to have_text("https://example.com/sso")
+ expect(page).to have_text("https://example.com/login")
+ expect(page).to have_text("Public certificate of identity provider")
+ expect(page).to have_button("Apply changes")
+ expect(page).to have_link("Cancel", href: saml_provider_path(provider))
+ expect(provider.reload.options).to eq(original_options)
+ end
+
+ it "applies the reviewed endpoints and certificate without fetching metadata again" do
+ original_options = provider.options.deep_dup
+ get refresh_metadata_saml_provider_path(provider)
+ token = metadata_token
+ stub_request(:get, metadata_url).to_return(status: 200, body: metadata.gsub("/login", "/other-login"))
+
+ post apply_metadata_saml_provider_path(provider), params: { metadata_token: token }
+
+ expect(response).to redirect_to(saml_provider_path(provider))
+ expect(flash[:notice]).to eq(I18n.t("saml.metadata_parser.success"))
+ provider.reload
+ expect(provider.idp_sso_service_url).to eq("https://example.com/login")
+ expect(provider.idp_slo_service_url).to eq("https://example.com/logout")
+ expect(provider.idp_entity_id).to eq("urn:dev-example.com")
+ certificate = Nokogiri::XML(metadata).at_xpath("//*[local-name()='X509Certificate']").text
+ expect(provider.idp_cert).to eq(OneLogin::RubySaml::Utils.format_cert(certificate))
+ expect(provider.last_metadata_update).to be > 1.minute.ago
+ expect(provider.metadata_url).to eq(metadata_url)
+ expect(provider.options.slice("mapping_login", "mapping_mail", "certificate", "private_key", "authn_requests_signed"))
+ .to eq(original_options.slice("mapping_login", "mapping_mail", "certificate", "private_key", "authn_requests_signed"))
+ expect(WebMock).to have_requested(:get, metadata_url).once
+ end
+
+ it "leaves the configuration unchanged when cancelling" do
+ original_options = provider.options.deep_dup
+ get refresh_metadata_saml_provider_path(provider)
+
+ get saml_provider_path(provider)
+
+ expect(provider.reload.options).to eq(original_options)
+ end
+
+ it "reports when the fetched metadata matches the current configuration" do
+ get refresh_metadata_saml_provider_path(provider)
+ post apply_metadata_saml_provider_path(provider), params: { metadata_token: }
+
+ get refresh_metadata_saml_provider_path(provider)
+
+ expect(page).to have_text("The metadata matches the current configuration.")
+ end
+
+ it "leaves the configuration unchanged when fetching metadata fails" do
+ original_options = provider.options.deep_dup
+ stub_request(:get, metadata_url).to_return(status: 500)
+
+ get refresh_metadata_saml_provider_path(provider)
+
+ expect(response).to redirect_to(saml_provider_path(provider))
+ expect(flash[:error]).to include("Failed to retrieve the identity provider metadata")
+ expect(provider.reload.options).to eq(original_options)
+ end
+
+ it "rejects malformed metadata without changing the configuration" do
+ original_options = provider.options.deep_dup
+ stub_request(:get, metadata_url).to_return(status: 200, body: "")
+
+ get refresh_metadata_saml_provider_path(provider)
+
+ expect(response).to redirect_to(saml_provider_path(provider))
+ expect(flash[:error]).to include("Failed to retrieve the identity provider metadata")
+ expect(provider.reload.options).to eq(original_options)
+ end
+
+ it "blocks unsafe metadata hosts" do
+ allow(OpenProject::SsrfProtection).to receive(:safe_ip?).with("example.com").and_return(nil)
+
+ get refresh_metadata_saml_provider_path(provider)
+
+ expect(response).to redirect_to(saml_provider_path(provider))
+ expect(flash[:error]).to include("MetadataHostNotAllowedError")
+ expect(WebMock).not_to have_requested(:get, metadata_url)
+ end
+
+ it "rejects a modified preview token" do
+ get refresh_metadata_saml_provider_path(provider)
+ token = "#{metadata_token}invalid"
+ original_options = provider.reload.options.deep_dup
+
+ post apply_metadata_saml_provider_path(provider), params: { metadata_token: token }
+
+ expect(flash[:error]).to eq(I18n.t("saml.providers.apply_metadata.expired"))
+ expect(provider.reload.options).to eq(original_options)
+ end
+
+ it "rejects a missing preview token" do
+ post apply_metadata_saml_provider_path(provider)
+
+ expect(flash[:error]).to eq(I18n.t("saml.providers.apply_metadata.expired"))
+ end
+
+ it "rejects an expired preview" do
+ get refresh_metadata_saml_provider_path(provider)
+ token = metadata_token
+ original_options = provider.reload.options.deep_dup
+
+ travel 16.minutes do
+ post apply_metadata_saml_provider_path(provider), params: { metadata_token: token }
+ end
+
+ expect(flash[:error]).to eq(I18n.t("saml.providers.apply_metadata.expired"))
+ expect(provider.reload.options).to eq(original_options)
+ end
+
+ it "rejects a preview if the provider has since changed" do
+ get refresh_metadata_saml_provider_path(provider)
+ token = metadata_token
+ provider.update!(idp_sso_service_url: "https://example.com/changed")
+
+ post apply_metadata_saml_provider_path(provider), params: { metadata_token: token }
+
+ expect(flash[:error]).to eq(I18n.t("saml.providers.apply_metadata.provider_changed"))
+ expect(provider.reload.idp_sso_service_url).to eq("https://example.com/changed")
+ end
+
+ it "rejects a preview for a different provider" do
+ get refresh_metadata_saml_provider_path(provider)
+ token = metadata_token
+ other_provider = create(:saml_provider, metadata_url:)
+
+ post apply_metadata_saml_provider_path(other_provider), params: { metadata_token: token }
+
+ expect(flash[:error]).to eq(I18n.t("saml.providers.apply_metadata.expired"))
+ expect(other_provider.reload.idp_sso_service_url).to eq("https://example.com/sso")
+ end
+
+ context "without a metadata URL" do
+ let(:provider) { create(:saml_provider, metadata_xml: metadata) }
+
+ it "does not offer or allow refreshing" do
+ get saml_provider_path(provider)
+ expect(page).to have_no_link("Refresh metadata")
+
+ get refresh_metadata_saml_provider_path(provider)
+ expect(response).to have_http_status(:not_found)
+
+ post apply_metadata_saml_provider_path(provider)
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context "with an environment-managed provider" do
+ before do
+ allow(Setting).to receive(:seed_saml_provider).and_return({ provider.slug => {} })
+ end
+
+ it "does not offer or allow refreshing" do
+ get saml_provider_path(provider)
+ expect(page).to have_no_link("Refresh metadata")
+
+ get refresh_metadata_saml_provider_path(provider)
+ expect(response).to redirect_to(saml_provider_path(provider))
+
+ post apply_metadata_saml_provider_path(provider)
+ expect(response).to redirect_to(saml_provider_path(provider))
+ expect(WebMock).not_to have_requested(:get, metadata_url)
+ end
+ end
+
+ context "without admin permissions" do
+ let(:admin) { create(:user) }
+
+ it "does not allow previewing or applying metadata" do
+ get refresh_metadata_saml_provider_path(provider)
+ expect(response).to have_http_status(:forbidden)
+
+ post apply_metadata_saml_provider_path(provider)
+ expect(response).to have_http_status(:forbidden)
+ expect(WebMock).not_to have_requested(:get, metadata_url)
+ end
+ end
+
+ context "without Enterprise", with_ee: [] do
+ it "does not allow previewing or applying metadata" do
+ get refresh_metadata_saml_provider_path(provider)
+ expect(response).to redirect_to(saml_providers_path)
+
+ post apply_metadata_saml_provider_path(provider)
+ expect(response).to redirect_to(saml_providers_path)
+ expect(WebMock).not_to have_requested(:get, metadata_url)
+ end
+ end
+end