From 80a154b3bf06d06ce9d978bb7e5cee9bdae600b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 15 Oct 2026 08:50:34 +0200 Subject: [PATCH 1/3] Allow refreshing metadata manually --- .../metadata_changes/row_component.rb | 59 ++++ .../metadata_changes/table_component.rb | 58 ++++ .../refresh_metadata_component.html.erb | 14 + .../side_panel/refresh_metadata_component.rb | 44 +++ .../providers/side_panel_component.html.erb | 3 +- .../controllers/saml/providers_controller.rb | 62 ++++- .../services/saml/update_metadata_service.rb | 3 +- .../saml/providers/refresh_metadata.html.erb | 34 +++ modules/auth_saml/config/locales/en.yml | 11 + modules/auth_saml/config/routes.rb | 2 + .../features/administration/saml_crud_spec.rb | 31 +++ .../requests/saml_metadata_refresh_spec.rb | 252 ++++++++++++++++++ 12 files changed, 568 insertions(+), 5 deletions(-) create mode 100644 modules/auth_saml/app/components/saml/providers/metadata_changes/row_component.rb create mode 100644 modules/auth_saml/app/components/saml/providers/metadata_changes/table_component.rb create mode 100644 modules/auth_saml/app/components/saml/providers/side_panel/refresh_metadata_component.html.erb create mode 100644 modules/auth_saml/app/components/saml/providers/side_panel/refresh_metadata_component.rb create mode 100644 modules/auth_saml/app/views/saml/providers/refresh_metadata.html.erb create mode 100644 modules/auth_saml/spec/requests/saml_metadata_refresh_spec.rb 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..ade31041eb13 --- /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 + I18n.t("saml.providers.metadata_refresh.title") + end + + def headers + [ + [:attribute, { caption: I18n.t(:label_attribute) }], + [:current_value, { caption: I18n.t("saml.providers.metadata_refresh.current_value") }], + [:new_value, { caption: I18n.t("saml.providers.metadata_refresh.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..8568624819ca --- /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 { I18n.t("saml.providers.metadata_refresh.side_panel_description") } + + render( + Primer::Beta::Button.new( + tag: :a, + href: refresh_metadata_saml_provider_path(provider), + data: { turbo_prefetch: false } + ) + ) { I18n.t("saml.providers.metadata_refresh.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..c01a985d8a66 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 + options = metadata_verifier.verified(params[:metadata_token], purpose: metadata_token_purpose) + + if options + apply_metadata_options(options) + else + flash[:error] = I18n.t("saml.providers.metadata_refresh.expired") + end + + redirect_to saml_provider_path(@provider) + end + def update call = Saml::Providers::UpdateService .new(model: @provider, user: User.current) @@ -131,6 +156,39 @@ 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( + @metadata_changes.merge("last_metadata_update" => @provider.last_metadata_update), + purpose: metadata_token_purpose, + expires_in: 15.minutes + ) + 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 + @provider.cache_key_with_version + 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..9bd018f74b34 --- /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("saml.providers.metadata_refresh.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("saml.providers.metadata_refresh.title")] + ) %> + <% header.with_description { t("saml.providers.metadata_refresh.description") } %> +<% end %> + +<% if @metadata_changes.empty? %> + <%= render(Primer::Alpha::Banner.new) { t("saml.providers.metadata_refresh.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("saml.providers.metadata_refresh.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..2ab883973909 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 @@ -147,6 +148,16 @@ 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_refresh: + apply: "Apply changes" + button: "Refresh metadata" + current_value: "Current value" + description: "Review the configuration fetched from your identity provider. Changes are saved only when you apply them." + expired: "This metadata preview has expired or the provider has changed. Refresh the metadata again to review the latest configuration." + new_value: "New value" + no_changes: "The metadata matches the current configuration." + side_panel_description: "You can check if the configuration fetched from your identity provider has changed." + title: "Review metadata changes" no_results_table: No SAML identity providers have been defined yet. notice_created: A new SAML identity provider was successfully created. 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..53c2572befc2 --- /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.metadata_refresh.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.metadata_refresh.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.metadata_refresh.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.metadata_refresh.expired")) + 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.metadata_refresh.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 From e62d3a44d83a26045904a2ececd8af5189943c6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 26 Oct 2026 13:18:50 +0100 Subject: [PATCH 2/3] Improve verifier messages --- .../controllers/saml/providers_controller.rb | 23 ++++++++++++++----- modules/auth_saml/config/locales/en.yml | 3 ++- .../requests/saml_metadata_refresh_spec.rb | 2 +- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index c01a985d8a66..10ceb22f7289 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -111,12 +111,12 @@ def refresh_metadata end def apply_metadata - options = metadata_verifier.verified(params[:metadata_token], purpose: metadata_token_purpose) + preview = metadata_verifier.verified(params[:metadata_token], purpose: metadata_token_purpose) - if options - apply_metadata_options(options) + if (error = metadata_preview_error(preview)) + flash[:error] = error else - flash[:error] = I18n.t("saml.providers.metadata_refresh.expired") + apply_metadata_options(preview["options"]) end redirect_to saml_provider_path(@provider) @@ -161,12 +161,23 @@ def prepare_metadata_preview key != "last_metadata_update" && value != @provider.options_in_database[key] end @metadata_token = metadata_verifier.generate( - @metadata_changes.merge("last_metadata_update" => @provider.last_metadata_update), + { + "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? + I18n.t("saml.providers.metadata_refresh.expired") + elsif preview["provider_version"] != @provider.cache_key_with_version + I18n.t("saml.providers.metadata_refresh.provider_changed") + end + end + def apply_metadata_options(options) call = Saml::Providers::UpdateService.new(model: @provider, user: User.current).call(options:) @@ -186,7 +197,7 @@ def metadata_verifier end def metadata_token_purpose - @provider.cache_key_with_version + "apply_metadata/#{@provider.id}" end def successful_save_response diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 2ab883973909..d17addd4e71d 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -153,9 +153,10 @@ en: button: "Refresh metadata" current_value: "Current value" description: "Review the configuration fetched from your identity provider. Changes are saved only when you apply them." - expired: "This metadata preview has expired or the provider has changed. Refresh the metadata again to review the latest configuration." + expired: "This metadata preview has expired or is invalid. Refresh the metadata again to review the latest configuration." new_value: "New value" no_changes: "The metadata matches the current configuration." + provider_changed: "The provider configuration has changed since this preview. Refresh the metadata again to review the latest configuration." side_panel_description: "You can check if the configuration fetched from your identity provider has changed." title: "Review metadata changes" diff --git a/modules/auth_saml/spec/requests/saml_metadata_refresh_spec.rb b/modules/auth_saml/spec/requests/saml_metadata_refresh_spec.rb index 53c2572befc2..3b8b97cc5177 100644 --- a/modules/auth_saml/spec/requests/saml_metadata_refresh_spec.rb +++ b/modules/auth_saml/spec/requests/saml_metadata_refresh_spec.rb @@ -178,7 +178,7 @@ post apply_metadata_saml_provider_path(provider), params: { metadata_token: token } - expect(flash[:error]).to eq(I18n.t("saml.providers.metadata_refresh.expired")) + expect(flash[:error]).to eq(I18n.t("saml.providers.metadata_refresh.provider_changed")) expect(provider.reload.idp_sso_service_url).to eq("https://example.com/changed") end From ec2803ec0b421ea8d70df13a8a36102a48369f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 26 Oct 2026 13:29:23 +0100 Subject: [PATCH 3/3] Move i18n keys so that relative key lookup works more easily --- .../metadata_changes/table_component.rb | 6 ++-- .../refresh_metadata_component.html.erb | 4 +-- .../controllers/saml/providers_controller.rb | 4 +-- .../saml/providers/refresh_metadata.html.erb | 10 +++---- modules/auth_saml/config/locales/en.yml | 28 +++++++++++-------- .../requests/saml_metadata_refresh_spec.rb | 10 +++---- 6 files changed, 34 insertions(+), 28 deletions(-) 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 index ade31041eb13..f795510e7ff5 100644 --- 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 @@ -42,14 +42,14 @@ def row_class end def mobile_title - I18n.t("saml.providers.metadata_refresh.title") + t(".mobile_title") end def headers [ [:attribute, { caption: I18n.t(:label_attribute) }], - [:current_value, { caption: I18n.t("saml.providers.metadata_refresh.current_value") }], - [:new_value, { caption: I18n.t("saml.providers.metadata_refresh.new_value") }] + [:current_value, { caption: t(".current_value") }], + [:new_value, { caption: t(".new_value") }] ] 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 index 8568624819ca..7054fc93a315 100644 --- 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 @@ -1,7 +1,7 @@ <%= 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 { I18n.t("saml.providers.metadata_refresh.side_panel_description") } + section.with_description { t(".description") } render( Primer::Beta::Button.new( @@ -9,6 +9,6 @@ href: refresh_metadata_saml_provider_path(provider), data: { turbo_prefetch: false } ) - ) { I18n.t("saml.providers.metadata_refresh.button") } + ) { t(".button") } end %> diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index 10ceb22f7289..bbec77323588 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -172,9 +172,9 @@ def prepare_metadata_preview def metadata_preview_error(preview) if preview.nil? - I18n.t("saml.providers.metadata_refresh.expired") + t(".expired") elsif preview["provider_version"] != @provider.cache_key_with_version - I18n.t("saml.providers.metadata_refresh.provider_changed") + t(".provider_changed") end end 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 index 9bd018f74b34..6ccc45741c0b 100644 --- a/modules/auth_saml/app/views/saml/providers/refresh_metadata.html.erb +++ b/modules/auth_saml/app/views/saml/providers/refresh_metadata.html.erb @@ -5,18 +5,18 @@ <% end %> <%= render(Primer::OpenProject::PageHeader.new) do |header| %> - <% header.with_title { t("saml.providers.metadata_refresh.title") } %> + <% 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("saml.providers.metadata_refresh.title")] + t(".title")] ) %> - <% header.with_description { t("saml.providers.metadata_refresh.description") } %> + <% header.with_description { t(".description") } %> <% end %> <% if @metadata_changes.empty? %> - <%= render(Primer::Alpha::Banner.new) { t("saml.providers.metadata_refresh.no_changes") } %> + <%= render(Primer::Alpha::Banner.new) { t(".no_changes") } %> <% else %> <%= render(Saml::Providers::MetadataChanges::TableComponent.new(rows: @metadata_changes.to_a, provider: @provider)) %> <% end %> @@ -25,7 +25,7 @@ <%= 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("saml.providers.metadata_refresh.apply") } + 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) } diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index d17addd4e71d..1d220f892ed7 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -129,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 @@ -148,21 +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_refresh: - apply: "Apply changes" - button: "Refresh metadata" - current_value: "Current value" - description: "Review the configuration fetched from your identity provider. Changes are saved only when you apply them." - expired: "This metadata preview has expired or is invalid. Refresh the metadata again to review the latest configuration." - new_value: "New value" - no_changes: "The metadata matches the current configuration." - provider_changed: "The provider configuration has changed since this preview. Refresh the metadata again to review the latest configuration." - side_panel_description: "You can check if the configuration fetched from your identity provider has changed." - title: "Review metadata changes" + 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: > @@ -186,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/spec/requests/saml_metadata_refresh_spec.rb b/modules/auth_saml/spec/requests/saml_metadata_refresh_spec.rb index 3b8b97cc5177..29ccf3027053 100644 --- a/modules/auth_saml/spec/requests/saml_metadata_refresh_spec.rb +++ b/modules/auth_saml/spec/requests/saml_metadata_refresh_spec.rb @@ -148,14 +148,14 @@ post apply_metadata_saml_provider_path(provider), params: { metadata_token: token } - expect(flash[:error]).to eq(I18n.t("saml.providers.metadata_refresh.expired")) + 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.metadata_refresh.expired")) + expect(flash[:error]).to eq(I18n.t("saml.providers.apply_metadata.expired")) end it "rejects an expired preview" do @@ -167,7 +167,7 @@ post apply_metadata_saml_provider_path(provider), params: { metadata_token: token } end - expect(flash[:error]).to eq(I18n.t("saml.providers.metadata_refresh.expired")) + expect(flash[:error]).to eq(I18n.t("saml.providers.apply_metadata.expired")) expect(provider.reload.options).to eq(original_options) end @@ -178,7 +178,7 @@ post apply_metadata_saml_provider_path(provider), params: { metadata_token: token } - expect(flash[:error]).to eq(I18n.t("saml.providers.metadata_refresh.provider_changed")) + 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 @@ -189,7 +189,7 @@ post apply_metadata_saml_provider_path(other_provider), params: { metadata_token: token } - expect(flash[:error]).to eq(I18n.t("saml.providers.metadata_refresh.expired")) + 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