diff --git a/app/components/op_primer/border_box_table_component.html.erb b/app/components/op_primer/border_box_table_component.html.erb
index 6b28c0c49e19..293aa2d79ed5 100644
--- a/app/components/op_primer/border_box_table_component.html.erb
+++ b/app/components/op_primer/border_box_table_component.html.erb
@@ -44,6 +44,10 @@ See COPYRIGHT and LICENSE files for more details.
end
end
+ if rows.empty?
+ component.with_row(scheme: :default) { render_blank_slate }
+ end
+
rows.each do |row|
component.with_row(scheme: :default) do
render(row_class.new(row:, table: self))
diff --git a/app/components/op_primer/border_box_table_component.rb b/app/components/op_primer/border_box_table_component.rb
index 84f5dee83613..f0a464c8c6f3 100644
--- a/app/components/op_primer/border_box_table_component.rb
+++ b/app/components/op_primer/border_box_table_component.rb
@@ -54,5 +54,25 @@ def has_actions?
def sortable?
false
end
+
+ def render_blank_slate
+ render(Primer::Beta::Blankslate.new(border: false)) do |component|
+ component.with_visual_icon(icon: blank_icon, size: :medium) if blank_icon
+ component.with_heading(tag: :h2) { blank_title }
+ component.with_description { blank_description }
+ end
+ end
+
+ def blank_title
+ I18n.t(:label_nothing_display)
+ end
+
+ def blank_description
+ I18n.t(:no_results_title_text)
+ end
+
+ def blank_icon
+ nil
+ end
end
end
diff --git a/app/components/op_primer/copy_to_clipboard_component.html.erb b/app/components/op_primer/copy_to_clipboard_component.html.erb
new file mode 100644
index 000000000000..0546b100c46c
--- /dev/null
+++ b/app/components/op_primer/copy_to_clipboard_component.html.erb
@@ -0,0 +1,21 @@
+<%=
+ flex_layout(align_items: :center, **@system_arguments) do |flex|
+ if @scheme == :link
+ flex.with_column(classes: "ellipsis") do
+ render(Primer::Beta::Link.new(
+ id: @id,
+ href: value,
+ title: value,
+ target: :_blank
+ )) { value }
+ end
+ else
+ flex.with_column(classes: "ellipsis") do
+ render(Primer::Beta::Text.new(title: value)) { value }
+ end
+ end
+ flex.with_column(ml: 1) do
+ render(Primer::Beta::ClipboardCopy.new(value:, "aria-label": t(:button_copy_to_clipboard)))
+ end
+ end
+%>
diff --git a/app/components/op_primer/copy_to_clipboard_component.rb b/app/components/op_primer/copy_to_clipboard_component.rb
new file mode 100644
index 000000000000..c892938dc53f
--- /dev/null
+++ b/app/components/op_primer/copy_to_clipboard_component.rb
@@ -0,0 +1,43 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module OpPrimer
+ class CopyToClipboardComponent < ApplicationComponent
+ include OpPrimer::ComponentHelpers
+
+ alias_method :value, :model
+
+ def initialize(value = nil, scheme: :value, **system_arguments)
+ super(value)
+
+ @scheme = scheme
+ @system_arguments = system_arguments
+ @id = SecureRandom.hex(8)
+ end
+ end
+end
diff --git a/app/components/op_turbo/stream_component.html.erb b/app/components/op_turbo/stream_component.html.erb
index bac890e181e1..8a0a9e20775e 100644
--- a/app/components/op_turbo/stream_component.html.erb
+++ b/app/components/op_turbo/stream_component.html.erb
@@ -26,11 +26,10 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++#%>
-
+<%= content_tag("turbo-stream", action: @action, target: @target, **@turbo_stream_args) do %>
<% if @template %>
<%= @template %>
<% end %>
-
-
+<% end %>
diff --git a/app/components/op_turbo/stream_component.rb b/app/components/op_turbo/stream_component.rb
index 2fb2ecef9826..43fa04eaab70 100644
--- a/app/components/op_turbo/stream_component.rb
+++ b/app/components/op_turbo/stream_component.rb
@@ -28,9 +28,10 @@
module OpTurbo
class StreamComponent < ApplicationComponent
- def initialize(template:, action:, target:)
+ def initialize(action:, target:, template: nil, **turbo_stream_args)
super()
+ @turbo_stream_args = turbo_stream_args
@template = template
@action = action
@target = target
diff --git a/app/controllers/concerns/op_turbo/component_stream.rb b/app/controllers/concerns/op_turbo/component_stream.rb
index 125a26975e87..6d3b3fa27162 100644
--- a/app/controllers/concerns/op_turbo/component_stream.rb
+++ b/app/controllers/concerns/op_turbo/component_stream.rb
@@ -39,6 +39,7 @@ def respond_to_with_turbo_streams(status: turbo_status, &format_block)
yield(format) if format_block
end
end
+
alias_method :respond_with_turbo_streams, :respond_to_with_turbo_streams
def update_via_turbo_stream(component:, status: :ok)
@@ -82,6 +83,12 @@ def update_flash_message_via_turbo_stream(message:, component: OpPrimer::FlashCo
turbo_streams << instance.render_as_turbo_stream(view_context:, action: :flash)
end
+ def scroll_into_view_via_turbo_stream(target, behavior: :auto, block: :start)
+ turbo_streams << OpTurbo::StreamComponent
+ .new(action: :scroll_into_view, target:, behavior:, block:)
+ .render_in(view_context)
+ end
+
def turbo_streams
@turbo_streams ||= []
end
diff --git a/app/services/authorization/enterprise_service.rb b/app/services/authorization/enterprise_service.rb
index f07c857f0dae..7ddd9730e29f 100644
--- a/app/services/authorization/enterprise_service.rb
+++ b/app/services/authorization/enterprise_service.rb
@@ -41,10 +41,10 @@ class Authorization::EnterpriseService
grid_widget_wp_graph
ldap_groups
one_drive_sharepoint_file_storage
- openid_providers
placeholder_users
project_list_sharing
readonly_work_packages
+ sso_auth_providers
team_planner_view
two_factor_authentication
virus_scanning
diff --git a/app/services/service_result.rb b/app/services/service_result.rb
index 36b35cc7943f..950a140f5d49 100644
--- a/app/services/service_result.rb
+++ b/app/services/service_result.rb
@@ -35,6 +35,7 @@ class ServiceResult
attr_accessor :success,
:result,
:errors,
+ :message,
:dependent_results
attr_writer :state
diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb
index 812927a7166d..939f6132387b 100644
--- a/app/validators/url_validator.rb
+++ b/app/validators/url_validator.rb
@@ -13,8 +13,8 @@ def validate_each(record, attribute, value)
end
def parse(value)
- url = URI.parse(value)
- rescue StandardError => e
+ URI.parse(value.to_s.strip)
+ rescue StandardError
nil
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 0b0ee449c0ae..b2503096c448 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1450,6 +1450,7 @@ en:
button_expand_all: "Expand all"
button_favorite: "Add to favorites"
button_filter: "Filter"
+ button_finish_setup: "Finish setup"
button_generate: "Generate"
button_list: "List"
button_lock: "Lock"
@@ -2118,6 +2119,7 @@ en:
label_calendars_and_dates: "Calendars and dates"
label_calendar_show: "Show Calendar"
label_category: "Category"
+ label_completed: Completed
label_consent_settings: "User Consent"
label_wiki_menu_item: Wiki menu item
label_select_main_menu_item: Select new main menu item
@@ -2277,6 +2279,7 @@ en:
label_inactive: "Inactive"
label_incoming_emails: "Incoming emails"
label_includes: "includes"
+ label_incomplete: Incomplete
label_include_sub_projects: Include sub-projects
label_index_by_date: "Index by date"
label_index_by_title: "Index by title"
@@ -2390,6 +2393,7 @@ en:
label_no_parent_page: "No parent page"
label_nothing_display: "Nothing to display"
label_nobody: "nobody"
+ label_not_configured: "Not configured"
label_not_found: "not found"
label_none: "none"
label_none_parentheses: "(none)"
diff --git a/frontend/src/global_styles/openproject.sass b/frontend/src/global_styles/openproject.sass
index 6048a0657f7b..bcede07f87ec 100644
--- a/frontend/src/global_styles/openproject.sass
+++ b/frontend/src/global_styles/openproject.sass
@@ -22,6 +22,7 @@
@import "../../../modules/meeting/app/components/_index.sass"
@import "../../../modules/overviews/app/components/_index.sass"
@import "../../../modules/storages/app/components/_index.sass"
+@import "../../../modules/auth_saml/app/components/_index.sass"
// Component specific Styles
@import "../../../app/components/_index.sass"
diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass
index 570bc7c90340..227f32c61b26 100644
--- a/frontend/src/global_styles/primer/_overrides.sass
+++ b/frontend/src/global_styles/primer/_overrides.sass
@@ -52,6 +52,9 @@ action-menu
@media screen and (min-width: $breakpoint-sm)
scroll-behavior: smooth
+ul.SegmentedControl
+ margin-left: 0
+
/* Remove margin-left: 2rem from Breadcrumbs */
#breadcrumb,
page-header,
diff --git a/frontend/src/stimulus/controllers/show-when-checked.controller.ts b/frontend/src/stimulus/controllers/show-when-checked.controller.ts
new file mode 100644
index 000000000000..1674cc7a5272
--- /dev/null
+++ b/frontend/src/stimulus/controllers/show-when-checked.controller.ts
@@ -0,0 +1,30 @@
+import { ApplicationController } from 'stimulus-use';
+
+export default class OpShowWhenCheckedController extends ApplicationController {
+ static targets = ['cause', 'effect'];
+
+ static values = {
+ reversed: Boolean,
+ };
+
+ declare reversedValue:boolean;
+
+ declare readonly hasReversedValue:boolean;
+
+ declare readonly effectTargets:HTMLInputElement[];
+
+ causeTargetConnected(target:HTMLElement) {
+ target.addEventListener('change', this.toggleDisabled.bind(this));
+ }
+
+ causeTargetDisconnected(target:HTMLElement) {
+ target.removeEventListener('change', this.toggleDisabled.bind(this));
+ }
+
+ private toggleDisabled(evt:InputEvent):void {
+ const checked = (evt.target as HTMLInputElement).checked;
+ this.effectTargets.forEach((el) => {
+ el.hidden = (this.hasReversedValue && this.reversedValue) ? checked : !checked;
+ });
+ }
+}
diff --git a/frontend/src/stimulus/controllers/show-when-value-selected.controller.ts b/frontend/src/stimulus/controllers/show-when-value-selected.controller.ts
new file mode 100644
index 000000000000..6c28e08c22c4
--- /dev/null
+++ b/frontend/src/stimulus/controllers/show-when-value-selected.controller.ts
@@ -0,0 +1,22 @@
+import { ApplicationController } from 'stimulus-use';
+
+export default class OpShowWhenValueSelectedController extends ApplicationController {
+ static targets = ['cause', 'effect'];
+
+ declare readonly effectTargets:HTMLInputElement[];
+
+ causeTargetConnected(target:HTMLElement) {
+ target.addEventListener('change', this.toggleDisabled.bind(this));
+ }
+
+ causeTargetDisconnected(target:HTMLElement) {
+ target.removeEventListener('change', this.toggleDisabled.bind(this));
+ }
+
+ private toggleDisabled(evt:InputEvent):void {
+ const value = (evt.target as HTMLInputElement).value;
+ this.effectTargets.forEach((el) => {
+ el.hidden = !(el.dataset.value === value);
+ });
+ }
+}
diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts
index a04e4bb5a18b..e1789326e07d 100644
--- a/frontend/src/stimulus/setup.ts
+++ b/frontend/src/stimulus/setup.ts
@@ -8,6 +8,8 @@ import RefreshOnFormChangesController from './controllers/refresh-on-form-change
import AsyncDialogController from './controllers/async-dialog.controller';
import PollForChangesController from './controllers/poll-for-changes.controller';
import TableHighlightingController from './controllers/table-highlighting.controller';
+import OpShowWhenCheckedController from './controllers/show-when-checked.controller';
+import OpShowWhenValueSelectedController from './controllers/show-when-value-selected.controller';
declare global {
interface Window {
@@ -26,7 +28,9 @@ instance.handleError = (error, message, detail) => {
instance.register('application', OpApplicationController);
instance.register('menus--main', MainMenuController);
+instance.register('show-when-checked', OpShowWhenCheckedController);
instance.register('disable-when-checked', OpDisableWhenCheckedController);
+instance.register('show-when-value-selected', OpShowWhenValueSelectedController);
instance.register('print', PrintController);
instance.register('refresh-on-form-changes', RefreshOnFormChangesController);
instance.register('async-dialog', AsyncDialogController);
diff --git a/lib/open_project/static/links.rb b/lib/open_project/static/links.rb
index 97bf710de570..738734f4ff0b 100644
--- a/lib/open_project/static/links.rb
+++ b/lib/open_project/static/links.rb
@@ -270,6 +270,11 @@ def static_links
href: "https://www.openproject.org/docs/system-admin-guide/manage-work-packages/work-package-status/#create-a-new-work-package-status"
}
},
+ sysadmin_docs: {
+ saml: {
+ href: "https://www.openproject.org/docs/system-admin-guide/authentication/saml/"
+ }
+ },
storage_docs: {
setup: {
href: "https://www.openproject.org/docs/system-admin-guide/integrations/storage/"
diff --git a/lib_static/redmine/i18n.rb b/lib_static/redmine/i18n.rb
index c54f18d32d91..01179d7dd3b1 100644
--- a/lib_static/redmine/i18n.rb
+++ b/lib_static/redmine/i18n.rb
@@ -91,13 +91,15 @@ def format_date(date)
#
# @param i18n_key [String] The I18n key to translate.
# @param links [Hash] Link names mapped to URLs.
- def link_translate(i18n_key, links: {}, locale: ::I18n.locale)
+ # @param target [String] optional HTML target attribute for the links.
+ def link_translate(i18n_key, links: {}, locale: ::I18n.locale, target: nil)
translation = ::I18n.t(i18n_key.to_s, locale:)
result = translation.scan(link_regex).inject(translation) do |t, matches|
link, text, key = matches
href = String(links[key.to_sym])
+ link_tag = content_tag(:a, text, href:, target:)
- t.sub(link, "#{text}")
+ t.sub(link, link_tag)
end
result.html_safe
diff --git a/lookbook/previews/op_primer/copy_to_clipboard_component_preview.rb b/lookbook/previews/op_primer/copy_to_clipboard_component_preview.rb
new file mode 100644
index 000000000000..971ad18ac71a
--- /dev/null
+++ b/lookbook/previews/op_primer/copy_to_clipboard_component_preview.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module OpPrimer
+ # @logical_path OpenProject/Primer
+ class CopyToClipboardComponentPreview < Lookbook::Preview
+ # @param value text
+ def default(value: "Copy me!")
+ render(OpPrimer::CopyToClipboardComponent.new(value))
+ end
+
+ # @param url text
+ def as_link(url: "http://example.org")
+ render(OpPrimer::CopyToClipboardComponent.new(url, scheme: :link))
+ end
+ end
+end
diff --git a/modules/auth_plugins/lib/omni_auth/flexible_strategy.rb b/modules/auth_plugins/lib/omni_auth/flexible_strategy.rb
index 0ed4caee53f5..1584fe66308e 100644
--- a/modules/auth_plugins/lib/omni_auth/flexible_strategy.rb
+++ b/modules/auth_plugins/lib/omni_auth/flexible_strategy.rb
@@ -41,7 +41,7 @@ def match_provider!
return false unless providers
@provider = providers.find do |p|
- (current_path =~ /#{path_for_provider(p.to_hash[:name])}/) == 0
+ current_path.match?(/#{path_for_provider(p.to_hash[:name])}(\/|\s*$)/)
end
if @provider
diff --git a/modules/auth_saml/app/components/_index.sass b/modules/auth_saml/app/components/_index.sass
new file mode 100644
index 000000000000..49f9321ba2d8
--- /dev/null
+++ b/modules/auth_saml/app/components/_index.sass
@@ -0,0 +1 @@
+@import "saml/providers/view_component"
diff --git a/modules/auth_saml/app/components/saml/providers/info_component.html.erb b/modules/auth_saml/app/components/saml/providers/info_component.html.erb
new file mode 100644
index 000000000000..8c1957332f56
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/info_component.html.erb
@@ -0,0 +1,3 @@
+<%= render(Primer::Beta::Heading.new(tag: :h4)) { I18n.t('saml.info.title') } %>
+
+<%= render(Primer::Beta::Text.new) { I18n.t('saml.info.description') }%>
diff --git a/modules/auth_saml/app/components/saml/providers/info_component.rb b/modules/auth_saml/app/components/saml/providers/info_component.rb
new file mode 100644
index 000000000000..b1d79afe940f
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/info_component.rb
@@ -0,0 +1,7 @@
+module Saml
+ module Providers
+ class InfoComponent < ApplicationComponent
+ alias_method :provider, :model
+ end
+ end
+end
diff --git a/modules/auth_saml/app/components/saml/providers/row_component.rb b/modules/auth_saml/app/components/saml/providers/row_component.rb
new file mode 100644
index 000000000000..30f6a3de52f0
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/row_component.rb
@@ -0,0 +1,80 @@
+module Saml
+ module Providers
+ class RowComponent < ::OpPrimer::BorderBoxRowComponent
+ def provider
+ model
+ end
+
+ def column_args(column)
+ if column == :name
+ { style: "grid-column: span 3" }
+ else
+ super
+ end
+ end
+
+ def name
+ concat render(Primer::Beta::Link.new(
+ font_weight: :bold,
+ href: url_for(action: :show, id: provider.id)
+ )) { provider.display_name || provider.name }
+
+ render_availability_label
+ render_idp_sso_service_url
+ end
+
+ def render_availability_label
+ unless provider.available?
+ concat render(Primer::Beta::Label.new(ml: 2, scheme: :attention, size: :medium)) { t(:label_incomplete) }
+ end
+ end
+
+ def render_idp_sso_service_url
+ if provider.idp_sso_service_url
+ concat render(Primer::Beta::Text.new(
+ tag: :p,
+ classes: "-break-word",
+ font_size: :small,
+ color: :subtle
+ )) { provider.idp_sso_service_url }
+ end
+ end
+
+ def button_links
+ [edit_link, delete_link].compact
+ end
+
+ def edit_link
+ link_to(
+ helpers.op_icon("icon icon-edit button--link"),
+ url_for(action: :edit, id: provider.id),
+ title: t(:button_edit)
+ )
+ end
+
+ def users
+ User.where("identity_url LIKE ?", "#{provider.slug}%").count.to_s
+ end
+
+ def creator
+ helpers.avatar(provider.creator, size: :mini, hide_name: false)
+ end
+
+ def created_at
+ helpers.format_time provider.created_at
+ end
+
+ def delete_link
+ return if provider.readonly
+
+ link_to(
+ helpers.op_icon("icon icon-delete button--link"),
+ url_for(action: :destroy, id: provider.id),
+ method: :delete,
+ data: { confirm: I18n.t(:text_are_you_sure) },
+ title: t(:button_delete)
+ )
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb
new file mode 100644
index 000000000000..ba286d6bd055
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/sections/form_component.html.erb
@@ -0,0 +1,40 @@
+<%=
+ primer_form_with(
+ id: "saml-providers-edit-form",
+ model: provider,
+ url:,
+ method: form_method
+ ) do |form|
+ flex_layout do |flex|
+ if @heading
+ flex.with_row do
+ render(Primer::Beta::Text.new(tag: :p, mb: 4, font_size: :small)) do
+ @heading
+ end
+ end
+ end
+
+ if @banner
+ flex.with_row(mb: 2) do
+ icon = @banner_scheme == :warning ? :alert : :info
+ render(Primer::Alpha::Banner.new(scheme: @banner_scheme, icon:)) do
+ @banner
+ end
+ end
+ end
+
+ flex.with_row do
+ render(@form_class.new(form, provider:))
+ end
+
+ flex.with_row(mt: 4) do
+ render(Saml::Providers::SubmitOrCancelForm.new(
+ form,
+ provider:,
+ submit_button_options: { label: button_label },
+ cancel_button_options: { hidden: edit_mode }
+ ))
+ end
+ end
+ end
+%>
diff --git a/modules/auth_saml/app/components/saml/providers/sections/form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/form_component.rb
new file mode 100644
index 000000000000..7935602098d3
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/sections/form_component.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+#
+module Saml::Providers::Sections
+ class FormComponent < SectionComponent
+ attr_reader :edit_state, :next_edit_state, :edit_mode
+
+ def initialize(provider, edit_state:, form_class:,
+ heading:, banner: nil, banner_scheme: :default,
+ next_edit_state: nil, edit_mode: nil)
+ super(provider)
+
+ @edit_state = edit_state
+ @next_edit_state = next_edit_state
+ @edit_mode = edit_mode
+ @form_class = form_class
+ @heading = heading
+ @banner = banner
+ @banner_scheme = banner_scheme
+ end
+
+ def url
+ if provider.new_record?
+ saml_providers_path(edit_state:, edit_mode:, next_edit_state:)
+ else
+ saml_provider_path(provider, edit_state:, edit_mode:, next_edit_state:)
+ end
+ end
+
+ def form_method
+ if provider.new_record?
+ :post
+ else
+ :put
+ end
+ end
+
+ def button_label
+ if edit_mode
+ I18n.t(:button_continue)
+ else
+ I18n.t(:button_update)
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb
new file mode 100644
index 000000000000..aeb532f046ba
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.html.erb
@@ -0,0 +1,56 @@
+<%=
+ primer_form_with(
+ model: provider,
+ id: "saml-providers-edit-form",
+ url: import_metadata_saml_provider_path(provider, edit_mode:),
+ data: {
+ controller: "show-when-value-selected"
+ },
+ method: :post,
+ ) do |form|
+ flex_layout do |flex|
+ if edit_mode
+ flex.with_row do
+ render(Primer::Beta::Text.new(tag: :p, mb: 4, font_size: :small)) do
+ t("saml.providers.section_texts.metadata_form")
+ end
+ end
+ else
+ flex.with_row do
+ render(Primer::Alpha::Banner.new(mb: 2, scheme: :warning, icon: :alert)) do
+ t("saml.providers.section_texts.metadata_form_banner")
+ end
+ end
+ end
+
+ flex.with_row do
+ render(Saml::Providers::MetadataOptionsForm.new(form, provider:))
+ end
+
+ flex.with_row(
+ mt: 2,
+ hidden: provider.metadata_url.blank?,
+ data: { value: :url, 'show-when-value-selected-target': "effect" }
+ ) do
+ render(Saml::Providers::MetadataUrlForm.new(form, provider:))
+ end
+
+ flex.with_row(
+ mt: 2,
+ hidden: provider.metadata_xml.blank?,
+ data: { value: :xml, 'show-when-value-selected-target': "effect" }
+ ) do
+ render(Saml::Providers::MetadataXmlForm.new(form, provider:))
+ end
+
+ flex.with_row(mt: 4) do
+ render(Saml::Providers::SubmitOrCancelForm.new(
+ form,
+ provider:,
+ submit_button_options: { label: button_label },
+ cancel_button_options: { hidden: edit_mode },
+ state: :metadata))
+ end
+ end
+ end
+%>
diff --git a/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.rb
new file mode 100644
index 000000000000..11d114d6390e
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/sections/metadata_form_component.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+#
+module Saml::Providers::Sections
+ class MetadataFormComponent < FormComponent
+ def initialize(provider, edit_mode: nil)
+ super(provider, edit_state: :metadata, edit_mode:, form_class: nil, heading: nil)
+ end
+ end
+end
diff --git a/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb
new file mode 100644
index 000000000000..e0a736b2cb5b
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/sections/request_attributes_form_component.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+#
+module Saml::Providers::Sections
+ class RequestAttributesFormComponent < FormComponent
+ def initialize(provider, edit_mode: nil)
+ super(provider,
+ edit_state: :requested_attributes,
+ edit_mode:,
+ form_class: Saml::Providers::RequestAttributesForm,
+ heading: I18n.t("saml.instructions.requested_attributes"))
+ end
+
+ def button_label
+ if edit_mode
+ I18n.t(:button_finish_setup)
+ else
+ I18n.t(:button_save)
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/components/saml/providers/sections/section_component.rb b/modules/auth_saml/app/components/saml/providers/sections/section_component.rb
new file mode 100644
index 000000000000..8f43d64b0e44
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/sections/section_component.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+#
+module Saml::Providers::Sections
+ class SectionComponent < ApplicationComponent
+ include OpPrimer::ComponentHelpers
+
+ attr_reader :provider
+
+ def initialize(provider)
+ super()
+ @provider = provider
+ end
+ end
+end
diff --git a/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb
new file mode 100644
index 000000000000..f2777b1de598
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/sections/show_component.html.erb
@@ -0,0 +1,45 @@
+<%=
+ grid_layout('op-saml-view-row',
+ tag: :div,
+ test_selector: "saml_provider_#{@target_state}",
+ align_items: :center) do |grid|
+ grid.with_area(:title, mr: 3) do
+ concat render(Primer::Beta::Text.new(font_weight: :bold)) { @heading }
+ if @label
+ concat render(Primer::Beta::Label.new(scheme: @label_scheme, ml: 1)) { @label }
+ end
+ end
+
+ grid.with_area(:description) do
+ render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do
+ @description
+ end
+ end
+
+ disabled = provider.seeded_from_env?
+ if show_edit?
+ grid.with_area(:action) do
+ flex_layout(justify_content: :flex_end) do |icons_container|
+ if @action
+ icons_container.with_column do
+ render(@action)
+ end
+ end
+
+ icons_container.with_column do
+ render(
+ Primer::Beta::IconButton.new(
+ icon: disabled ? :eye : :pencil,
+ tag: :a,
+ scheme: :invisible,
+ href: edit_saml_provider_path(provider, edit_state: @target_state),
+ data: { turbo: true, turbo_stream: true },
+ aria: { label: I18n.t(disabled ? :label_show : :label_edit) }
+ )
+ )
+ end
+ end
+ end
+ end
+ end
+%>
diff --git a/modules/auth_saml/app/components/saml/providers/sections/show_component.rb b/modules/auth_saml/app/components/saml/providers/sections/show_component.rb
new file mode 100644
index 000000000000..c6fdfa055e79
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/sections/show_component.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+#
+module Saml::Providers::Sections
+ class ShowComponent < SectionComponent
+ def initialize(provider, view_mode:, target_state:,
+ heading:, description:, action: nil, label: nil, label_scheme: :attention)
+ super(provider)
+
+ @target_state = target_state
+ @view_mode = view_mode
+ @heading = heading
+ @description = description
+ @label = label
+ @label_scheme = label_scheme
+ @action = action
+ end
+
+ def show_edit?
+ provider.persisted?
+ end
+ end
+end
diff --git a/modules/auth_saml/app/components/saml/providers/side_panel/information_component.html.erb b/modules/auth_saml/app/components/saml/providers/side_panel/information_component.html.erb
new file mode 100644
index 000000000000..7a38adc481b3
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/side_panel/information_component.html.erb
@@ -0,0 +1,24 @@
+<%=
+ render(Primer::OpenProject::SidePanel::Section.new) do |section|
+ section.with_title { I18n.t("saml.providers.label_openproject_information") }
+ section.with_description { I18n.t("saml.instructions.metadata_for_idp") }
+
+ component_collection do |collection|
+ collection.with_component(Primer::Beta::Heading.new(tag: :h5, mb: 1)) do
+ I18n.t("activemodel.attributes.saml/provider.sp_entity_id")
+ end
+
+ collection.with_component(
+ OpPrimer::CopyToClipboardComponent.new(provider.sp_entity_id, scheme: :input)
+ )
+
+ collection.with_component(Primer::Beta::Heading.new(tag: :h5, mt: 4, mb: 1)) do
+ I18n.t("activemodel.attributes.saml/provider.assertion_consumer_service_url")
+ end
+
+ collection.with_component(
+ OpPrimer::CopyToClipboardComponent.new(provider.callback_url, scheme: :link)
+ )
+ end
+ end
+%>
diff --git a/modules/auth_saml/app/components/saml/providers/side_panel/information_component.rb b/modules/auth_saml/app/components/saml/providers/side_panel/information_component.rb
new file mode 100644
index 000000000000..1d509fda9fd8
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/side_panel/information_component.rb
@@ -0,0 +1,39 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml::Providers
+ module SidePanel
+ class InformationComponent < ApplicationComponent
+ include ApplicationHelper
+ include OpTurbo::Streamable
+ include OpPrimer::ComponentHelpers
+
+ alias_method :provider, :model
+ end
+ end
+end
diff --git a/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.html.erb b/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.html.erb
new file mode 100644
index 000000000000..f8989607f588
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.html.erb
@@ -0,0 +1,19 @@
+<%=
+ render(Primer::OpenProject::SidePanel::Section.new) do |section|
+ section.with_title { I18n.t("saml.providers.label_metadata_endpoint") }
+ section.with_description { I18n.t("saml.instructions.sp_metadata_endpoint") }
+
+ flex_layout do |flex|
+ flex.with_column(classes: "ellipsis") do
+ render(Primer::Beta::Link.new(
+ href: metadata_endpoint,
+ title: metadata_endpoint,
+ target: :_blank
+ )) { metadata_endpoint }
+ end
+ flex.with_column(ml: 1) do
+ render(Primer::Beta::ClipboardCopy.new(value: metadata_endpoint, "aria-label": t(:button_copy_to_clipboard)))
+ end
+ end
+ end
+%>
diff --git a/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.rb b/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.rb
new file mode 100644
index 000000000000..9affbd43f2da
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/side_panel/metadata_component.rb
@@ -0,0 +1,43 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml::Providers
+ module SidePanel
+ class MetadataComponent < ApplicationComponent
+ include ApplicationHelper
+ include OpTurbo::Streamable
+ include OpPrimer::ComponentHelpers
+
+ alias_method :provider, :model
+
+ def metadata_endpoint
+ URI.join(helpers.root_url, "/auth/#{provider.slug}/metadata").to_s
+ 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
new file mode 100644
index 000000000000..c9d27ca3a71f
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb
@@ -0,0 +1,12 @@
+<%=
+ component_wrapper do
+ render(Primer::OpenProject::SidePanel.new(spacious: true)) do |panel|
+ [
+ Saml::Providers::SidePanel::MetadataComponent.new(@provider),
+ Saml::Providers::SidePanel::InformationComponent.new(@provider),
+ ].each do |component|
+ panel.with_section(component)
+ end
+ end
+ end
+%>
diff --git a/modules/auth_saml/app/components/saml/providers/side_panel_component.rb b/modules/auth_saml/app/components/saml/providers/side_panel_component.rb
new file mode 100644
index 000000000000..6c7328908b2f
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/side_panel_component.rb
@@ -0,0 +1,43 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class SidePanelComponent < ApplicationComponent
+ include ApplicationHelper
+ include OpTurbo::Streamable
+ include OpPrimer::ComponentHelpers
+
+ def initialize(provider)
+ super()
+
+ @provider = provider
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/components/saml/providers/table_component.rb b/modules/auth_saml/app/components/saml/providers/table_component.rb
new file mode 100644
index 000000000000..fd9d403a57ea
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/table_component.rb
@@ -0,0 +1,48 @@
+module Saml
+ module Providers
+ class TableComponent < ::OpPrimer::BorderBoxTableComponent
+ columns :name, :users, :creator, :created_at
+
+ def initial_sort
+ %i[id asc]
+ end
+
+ def header_args(column)
+ if column == :name
+ { style: "grid-column: span 3" }
+ else
+ super
+ end
+ end
+
+ def sortable?
+ false
+ end
+
+ def empty_row_message
+ I18n.t "saml.providers.no_results_table"
+ end
+
+ def headers
+ [
+ [:name, { caption: I18n.t("attributes.name") }],
+ [:users, { caption: I18n.t(:label_user_plural) }],
+ [:creator, { caption: I18n.t("js.label_created_by") }],
+ [:created_at, { caption: Saml::Provider.human_attribute_name(:created_at) }]
+ ]
+ end
+
+ def blank_title
+ I18n.t("saml.providers.label_empty_title")
+ end
+
+ def blank_description
+ I18n.t("saml.providers.label_empty_description")
+ end
+
+ def blank_icon
+ :key
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/components/saml/providers/view_component.html.erb b/modules/auth_saml/app/components/saml/providers/view_component.html.erb
new file mode 100644
index 000000000000..51732c14c1ae
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/view_component.html.erb
@@ -0,0 +1,152 @@
+<%= component_wrapper do %>
+ <% if provider.seeded_from_env? %>
+ <%=
+ render(Primer::Alpha::Banner.new(mb: 2, scheme: :default, icon: :bell, spacious: true)) do
+ I18n.t("saml.providers.seeded_from_env")
+ end
+ %>
+ <% end %>
+
+ <%= render(border_box_container) do |component|
+ component.with_header(color: :muted) do
+ render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t("activemodel.attributes.saml/provider.display_name") }
+ end
+
+ component.with_row(scheme: :default) do
+ if edit_state == :name
+ render(Saml::Providers::Sections::FormComponent.new(
+ provider,
+ form_class: Saml::Providers::NameInputForm,
+ edit_state:,
+ next_edit_state: :metadata,
+ edit_mode:,
+ heading: nil
+ ))
+ else
+ render(Saml::Providers::Sections::ShowComponent.new(
+ provider,
+ view_mode:,
+ target_state: :name,
+ heading: t("saml.providers.singular"),
+ description: t("saml.providers.section_texts.display_name")
+ ))
+ end
+ end
+
+ component.with_row(scheme: :neutral, color: :muted) do
+ render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.label_automatic_configuration') }
+ end
+
+ component.with_row(scheme: :default) do
+ if edit_state == :metadata
+ render(Saml::Providers::Sections::MetadataFormComponent.new(
+ provider,
+ edit_mode:,
+ ))
+ else
+ render(Saml::Providers::Sections::ShowComponent.new(
+ provider,
+ target_state: :metadata,
+ view_mode:,
+ heading: t("saml.providers.label_metadata"),
+ description: t("saml.providers.section_texts.metadata"),
+ label: provider.has_metadata? ? t(:label_completed) : t(:label_not_configured),
+ label_scheme: provider.has_metadata? ? :success : :secondary
+ ))
+ end
+ end
+
+ component.with_row(scheme: :neutral, color: :muted) do
+ render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.section_headers.configuration') }
+ end
+
+ component.with_row(scheme: :default) do
+ if edit_state == :configuration
+ render(Saml::Providers::Sections::FormComponent.new(
+ provider,
+ form_class: Saml::Providers::ConfigurationForm,
+ edit_state:,
+ next_edit_state: :encryption,
+ edit_mode:,
+ banner: provider.last_metadata_update ? t("saml.providers.section_texts.configuration_metadata") : nil,
+ banner_scheme: :default,
+ heading: nil
+ ))
+ else
+ render(Saml::Providers::Sections::ShowComponent.new(
+ provider,
+ target_state: :configuration,
+ view_mode:,
+ heading: t("saml.providers.label_configuration_details"),
+ description: t("saml.providers.section_texts.configuration"),
+ label: (provider.persisted? && !provider.configured?) ? t(:label_incomplete) : nil,
+ ))
+ end
+ end
+
+ component.with_row(scheme: :default) do
+ if edit_state == :encryption
+ render(Saml::Providers::Sections::FormComponent.new(
+ provider,
+ form_class: Saml::Providers::EncryptionForm,
+ edit_state:,
+ next_edit_state: :mapping,
+ edit_mode:,
+ heading: t("saml.providers.section_texts.encryption_form")
+ ))
+ else
+ render(Saml::Providers::Sections::ShowComponent.new(
+ provider,
+ target_state: :encryption,
+ view_mode:,
+ heading: t("saml.providers.label_configuration_encryption"),
+ description: t("saml.providers.section_texts.encryption")
+ ))
+ end
+ end
+
+ component.with_row(scheme: :neutral, color: :muted) do
+ render(Primer::Beta::Text.new(font_weight: :semibold)) { I18n.t('saml.providers.section_headers.attributes') }
+ end
+
+ component.with_row(scheme: :default) do
+ if edit_state == :mapping
+ render(Saml::Providers::Sections::FormComponent.new(
+ provider,
+ form_class: Saml::Providers::MappingForm,
+ edit_state:,
+ next_edit_state: :requested_attributes,
+ edit_mode:,
+ heading: t("saml.instructions.mapping")
+ ))
+ else
+ render(Saml::Providers::Sections::ShowComponent.new(
+ provider,
+ target_state: :mapping,
+ view_mode:,
+ heading: t("saml.providers.label_mapping"),
+ description: t("saml.providers.section_texts.mapping"),
+ label: provider.mapping_configured? ? nil : t(:label_incomplete),
+ label_scheme: provider.mapping_configured? ? :success : :attention
+ ))
+ end
+ end
+ component.with_row(scheme: :default) do
+ if edit_state == :requested_attributes
+ render(Saml::Providers::Sections::RequestAttributesFormComponent.new(
+ provider,
+ edit_mode:
+ ))
+ else
+ render(Saml::Providers::Sections::ShowComponent.new(
+ provider,
+ target_state: :requested_attributes,
+ view_mode:,
+ heading: t("saml.providers.requested_attributes"),
+ description: t("saml.providers.section_texts.requested_attributes")
+ ))
+ end
+ end
+ end
+ %>
+<% end %>
diff --git a/modules/auth_saml/app/components/saml/providers/view_component.rb b/modules/auth_saml/app/components/saml/providers/view_component.rb
new file mode 100644
index 000000000000..a909c7f651fc
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/view_component.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+#
+module Saml::Providers
+ class ViewComponent < ApplicationComponent
+ include OpTurbo::Streamable
+ include OpPrimer::ComponentHelpers
+
+ options :view_mode, :edit_state, :edit_mode
+
+ alias_method :provider, :model
+ end
+end
diff --git a/modules/auth_saml/app/components/saml/providers/view_component.sass b/modules/auth_saml/app/components/saml/providers/view_component.sass
new file mode 100644
index 000000000000..c17632077ee2
--- /dev/null
+++ b/modules/auth_saml/app/components/saml/providers/view_component.sass
@@ -0,0 +1,4 @@
+.op-saml-view-row
+ display: grid
+ grid-template-columns: 3fr 1fr
+ grid-template-areas: "title action" "description action"
diff --git a/modules/auth_saml/app/constants/saml/defaults.rb b/modules/auth_saml/app/constants/saml/defaults.rb
new file mode 100644
index 000000000000..493129d56527
--- /dev/null
+++ b/modules/auth_saml/app/constants/saml/defaults.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Defaults
+ NAME_IDENTIFIER_FORMAT = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
+
+ SIGNATURE_METHODS = {
+ "RSA SHA-1" => XMLSecurity::Document::RSA_SHA1,
+ "RSA SHA-256" => XMLSecurity::Document::RSA_SHA256,
+ "RSA SHA-384" => XMLSecurity::Document::RSA_SHA384,
+ "RSA SHA-512" => XMLSecurity::Document::RSA_SHA512
+ }.freeze
+
+ DIGEST_METHODS = {
+ "SHA-1" => XMLSecurity::Document::SHA1,
+ "SHA-256" => XMLSecurity::Document::SHA256,
+ "SHA-384" => XMLSecurity::Document::SHA384,
+ "SHA-512" => XMLSecurity::Document::SHA512
+ }.freeze
+
+ NAME_IDENTIFIER_FORMATS = %w[
+ urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
+ urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified
+ urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
+ urn:oasis:names:tc:SAML:2.0:nameid-format:transient
+ ].freeze
+
+ ATTRIBUTE_FORMATS = %w[
+ urn:oasis:names:tc:SAML:2.0:attrname-format:basic
+ urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified
+ urn:oasis:names:tc:SAML:2.0:attrname-format:uri
+ ].freeze
+
+ MAIL_MAPPING = <<~STR
+ mail
+ email
+ Email
+ emailAddress
+ emailaddress
+ urn:oid:0.9.2342.19200300.100.1.3
+ http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
+ STR
+
+ FIRSTNAME_MAPPING = <<~STR
+ givenName
+ givenname
+ given_name
+ given_name
+ urn:oid:2.5.4.42
+ http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname
+ STR
+
+ LASTNAME_MAPPING = <<~STR
+ sn
+ surname
+ sur_name
+ given_name
+ urn:oid:2.5.4.4
+ http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname
+ STR
+ end
+end
diff --git a/modules/auth_saml/app/contracts/saml/providers/base_contract.rb b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb
new file mode 100644
index 000000000000..3e2a24ac82fe
--- /dev/null
+++ b/modules/auth_saml/app/contracts/saml/providers/base_contract.rb
@@ -0,0 +1,132 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+module Saml
+ module Providers
+ class BaseContract < ModelContract
+ include RequiresAdminGuard
+
+ def self.model
+ Saml::Provider
+ end
+
+ attribute :type
+ validate :type_is_saml_provider
+
+ attribute :display_name
+ attribute :slug
+ attribute :options
+ attribute :metadata_url
+ validates :metadata_url,
+ url: { allow_blank: true, allow_nil: true, schemes: %w[http https] },
+ if: -> { model.metadata_url_changed? }
+
+ attribute :idp_sso_service_url
+ validates :idp_sso_service_url,
+ url: { schemes: %w[http https] },
+ if: -> { model.idp_sso_service_url_changed? }
+
+ attribute :idp_slo_service_url
+ validates :idp_slo_service_url,
+ url: { allow_blank: true, allow_nil: true, schemes: %w[http https] },
+ if: -> { model.idp_slo_service_url_changed? }
+
+ attribute :idp_cert
+ validates_presence_of :idp_cert,
+ if: -> { model.idp_cert_changed? }
+ validate :idp_cert_not_expired,
+ if: -> { model.idp_cert_changed? && model.idp_cert.present? }
+
+ attribute :authn_requests_signed
+ validate :valid_certificate_key_pair
+
+ %i[mapping_mail mapping_login mapping_firstname mapping_lastname].each do |attr|
+ attribute attr
+ validates_presence_of attr, if: -> { model.public_send(:"#{attr}_changed?") }
+ end
+
+ def type_is_saml_provider
+ unless model.type == Saml::Provider.name
+ errors.add(:type, :inclusion)
+ end
+ end
+
+ def idp_cert_not_expired
+ unless model.idp_certificate_valid?
+ errors.add :idp_cert, :certificate_expired
+ end
+ rescue OpenSSL::X509::CertificateError => e
+ errors.add :idp_cert, :invalid_certificate, additional_message: e.message
+ end
+
+ def valid_certificate
+ if model.loaded_certificate.blank?
+ errors.add :certificate, :blank
+ end
+
+ if OneLogin::RubySaml::Utils.is_cert_expired(model.loaded_certificate)
+ errors.add :certificate, :certificate_expired
+ end
+ rescue OpenSSL::OpenSSLError => e
+ errors.add :certificate, :invalid_certificate, additional_message: e.message
+ end
+
+ def valid_sp_key
+ if model.loaded_private_key.blank?
+ errors.add :private_key, :blank
+ end
+ rescue OpenSSL::OpenSSLError => e
+ errors.add :private_key, :invalid_private_key, additional_message: e.message
+ end
+
+ def valid_certificate_key_pair
+ return unless should_test_certificate?
+ return if certificate_invalid?
+
+ cert = model.loaded_certificate
+ key = model.loaded_private_key
+
+ if cert && key && cert.public_key.public_to_pem != key.public_key.public_to_pem
+ errors.add :private_key, :unmatched_private_key
+ end
+ end
+
+ def certificate_invalid?
+ valid_certificate
+ valid_sp_key
+
+ errors.any?
+ end
+
+ def should_test_certificate?
+ return false unless model.certificate_changed? || model.private_key_changed?
+
+ model.certificate.present? || model.private_key.present?
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/contracts/saml/providers/create_contract.rb b/modules/auth_saml/app/contracts/saml/providers/create_contract.rb
new file mode 100644
index 000000000000..555aeac3604f
--- /dev/null
+++ b/modules/auth_saml/app/contracts/saml/providers/create_contract.rb
@@ -0,0 +1,33 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+module Saml
+ module Providers
+ class CreateContract < BaseContract
+ end
+ end
+end
diff --git a/modules/auth_saml/app/contracts/saml/providers/delete_contract.rb b/modules/auth_saml/app/contracts/saml/providers/delete_contract.rb
new file mode 100644
index 000000000000..aae9539da9f0
--- /dev/null
+++ b/modules/auth_saml/app/contracts/saml/providers/delete_contract.rb
@@ -0,0 +1,35 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class DeleteContract < ::DeleteContract
+ delete_permission :admin
+ end
+ end
+end
diff --git a/modules/auth_saml/app/contracts/saml/providers/update_contract.rb b/modules/auth_saml/app/contracts/saml/providers/update_contract.rb
new file mode 100644
index 000000000000..44a17f81feac
--- /dev/null
+++ b/modules/auth_saml/app/contracts/saml/providers/update_contract.rb
@@ -0,0 +1,34 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class UpdateContract < BaseContract
+ end
+ end
+end
diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb
new file mode 100644
index 000000000000..ab8a43a11fe4
--- /dev/null
+++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb
@@ -0,0 +1,201 @@
+module Saml
+ class ProvidersController < ::ApplicationController
+ include OpTurbo::ComponentStream
+
+ layout "admin"
+ menu_item :plugin_saml
+
+ before_action :require_admin
+ before_action :check_ee
+ before_action :find_provider, only: %i[show edit import_metadata update destroy]
+ before_action :check_provider_writable, only: %i[update import_metadata]
+ before_action :set_edit_state, only: %i[create edit update import_metadata]
+
+ def index
+ @providers = Saml::Provider.order(display_name: :asc)
+ end
+
+ def edit
+ respond_to do |format|
+ format.turbo_stream do
+ component = Saml::Providers::ViewComponent.new(@provider,
+ view_mode: :edit,
+ edit_mode: @edit_mode,
+ edit_state: @edit_state)
+ update_via_turbo_stream(component:)
+ scroll_into_view_via_turbo_stream("saml-providers-edit-form", behavior: :instant)
+ render turbo_stream: turbo_streams
+ end
+ format.html
+ end
+ end
+
+ def show
+ respond_to do |format|
+ format.turbo_stream do
+ component = Saml::Providers::ViewComponent.new(@provider,
+ view_mode: :show)
+ update_via_turbo_stream(component:)
+ render turbo_stream: turbo_streams
+ end
+ format.html
+ end
+ end
+
+ def new
+ @provider = ::Saml::Provider.new
+ end
+
+ def import_metadata
+ call = update_provider_metadata_call
+ @provider = call.result
+
+ if call.success?
+ if @edit_mode || @provider.last_metadata_update.present?
+ redirect_to edit_saml_provider_path(@provider,
+ anchor: "saml-providers-edit-form",
+ edit_mode: @edit_mode,
+ edit_state: :configuration)
+ else
+ redirect_to saml_provider_path(@provider)
+ end
+ else
+ @edit_state = :metadata
+
+ flash.now[:error] = call.message
+ render action: :edit
+ end
+ end
+
+ def create
+ call = ::Saml::Providers::CreateService
+ .new(user: User.current)
+ .call(**create_params)
+
+ @provider = call.result
+
+ if call.success?
+ successful_save_response
+ else
+ flash.now[:error] = call.message
+ render action: :new
+ end
+ end
+
+ def update
+ call = Saml::Providers::UpdateService
+ .new(model: @provider, user: User.current)
+ .call(options: update_params)
+
+ if call.success?
+ flash[:notice] = I18n.t(:notice_successful_update) unless @edit_mode
+ successful_save_response
+ else
+ @provider = call.result
+ render action: :edit
+ end
+ end
+
+ def destroy
+ call = ::Saml::Providers::DeleteService
+ .new(model: @provider, user: User.current)
+ .call
+
+ if call.success?
+ flash[:notice] = I18n.t(:notice_successful_delete)
+ else
+ flash[:error] = I18n.t(:error_failed_to_delete_entry)
+ end
+
+ redirect_to action: :index
+ end
+
+ private
+
+ def successful_save_response
+ respond_to do |format|
+ format.turbo_stream do
+ update_via_turbo_stream(
+ component: Saml::Providers::ViewComponent.new(
+ @provider,
+ edit_mode: @edit_mode,
+ edit_state: @next_edit_state,
+ view_mode: :show
+ )
+ )
+ render turbo_stream: turbo_streams
+ end
+ format.html do
+ if @edit_mode && @next_edit_state
+ redirect_to edit_saml_provider_path(@provider,
+ anchor: "saml-providers-edit-form",
+ edit_mode: true,
+ edit_state: @next_edit_state)
+ else
+ redirect_to saml_provider_path(@provider)
+ end
+ end
+ end
+ end
+
+ def check_ee
+ unless EnterpriseToken.allows_to?(:sso_auth_providers)
+ render template: "/saml/providers/upsale"
+ false
+ end
+ end
+
+ def default_breadcrumb; end
+
+ def show_local_breadcrumb
+ false
+ end
+
+ def update_provider_metadata_call
+ Saml::Providers::UpdateService
+ .new(model: @provider, user: User.current)
+ .call(import_params)
+ end
+
+ def import_params
+ options = params
+ .require(:saml_provider)
+ .permit(:metadata_url, :metadata_xml, :metadata)
+
+ if options[:metadata] == "none"
+ { metadata_url: nil, metadata_xml: nil }
+ else
+ options.slice(:metadata_url, :metadata_xml)
+ end
+ end
+
+ def create_params
+ params.require(:saml_provider).permit(:display_name)
+ end
+
+ def update_params
+ params
+ .require(:saml_provider)
+ .permit(:display_name, *Saml::Provider.stored_attributes[:options])
+ end
+
+ def find_provider
+ @provider = Saml::Provider.find(params[:id])
+ rescue ActiveRecord::RecordNotFound
+ render_404
+ end
+
+ def check_provider_writable
+ if @provider.seeded_from_env?
+ flash[:error] = I18n.t(:label_seeded_from_env_warning)
+ redirect_to saml_provider_path(@provider)
+ end
+ end
+
+ def set_edit_state
+ @edit_state = params[:edit_state].to_sym if params.key?(:edit_state)
+ @edit_mode = ActiveRecord::Type::Boolean.new.cast(params[:edit_mode])
+ @next_edit_state = params[:next_edit_state].to_sym if params.key?(:next_edit_state)
+ end
+ end
+end
diff --git a/modules/auth_saml/app/forms/saml/providers/base_form.rb b/modules/auth_saml/app/forms/saml/providers/base_form.rb
new file mode 100644
index 000000000000..bcc7f7d52264
--- /dev/null
+++ b/modules/auth_saml/app/forms/saml/providers/base_form.rb
@@ -0,0 +1,40 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class BaseForm < ApplicationForm
+ attr_reader :provider
+
+ def initialize(provider:)
+ super()
+ @provider = provider
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/forms/saml/providers/configuration_form.rb b/modules/auth_saml/app/forms/saml/providers/configuration_form.rb
new file mode 100644
index 000000000000..c917d3ec1c7b
--- /dev/null
+++ b/modules/auth_saml/app/forms/saml/providers/configuration_form.rb
@@ -0,0 +1,96 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class ConfigurationForm < BaseForm
+ form do |f|
+ f.text_field(
+ name: :sp_entity_id,
+ label: I18n.t("activemodel.attributes.saml/provider.sp_entity_id"),
+ caption: I18n.t("saml.instructions.sp_entity_id"),
+ disabled: provider.seeded_from_env?,
+ required: true,
+ input_width: :large
+ )
+ f.text_field(
+ name: :idp_sso_service_url,
+ label: I18n.t("activemodel.attributes.saml/provider.idp_sso_service_url"),
+ caption: I18n.t("saml.instructions.idp_sso_service_url"),
+ disabled: provider.seeded_from_env?,
+ required: true,
+ input_width: :large
+ )
+ f.text_field(
+ name: :idp_slo_service_url,
+ label: I18n.t("activemodel.attributes.saml/provider.idp_slo_service_url"),
+ caption: I18n.t("saml.instructions.idp_slo_service_url"),
+ disabled: provider.seeded_from_env?,
+ required: false,
+ input_width: :large
+ )
+ f.text_area(
+ name: :idp_cert,
+ rows: 10,
+ label: I18n.t("activemodel.attributes.saml/provider.idp_cert"),
+ caption: I18n.t("saml.instructions.idp_cert"),
+ disabled: provider.seeded_from_env?,
+ required: true,
+ input_width: :large
+ )
+ f.select_list(
+ name: "name_identifier_format",
+ label: I18n.t("activemodel.attributes.saml/provider.name_identifier_format"),
+ input_width: :large,
+ disabled: provider.seeded_from_env?,
+ caption: I18n.t("saml.instructions.name_identifier_format")
+ ) do |list|
+ Saml::Defaults::NAME_IDENTIFIER_FORMATS.each do |format|
+ list.option(label: format, value: format)
+ end
+ end
+ f.check_box(
+ name: :limit_self_registration,
+ label: I18n.t("activemodel.attributes.saml/provider.limit_self_registration"),
+ caption: I18n.t("saml.instructions.limit_self_registration"),
+ disabled: provider.seeded_from_env?,
+ required: false,
+ input_width: :large
+ )
+ f.text_field(
+ name: :icon,
+ label: I18n.t("activemodel.attributes.saml/provider.icon"),
+ caption: I18n.t("saml.instructions.icon"),
+ disabled: provider.seeded_from_env?,
+ required: false,
+ input_width: :large
+ )
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/forms/saml/providers/encryption_form.rb b/modules/auth_saml/app/forms/saml/providers/encryption_form.rb
new file mode 100644
index 000000000000..6ca178317713
--- /dev/null
+++ b/modules/auth_saml/app/forms/saml/providers/encryption_form.rb
@@ -0,0 +1,97 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class EncryptionForm < BaseForm
+ form do |f|
+ f.check_box(
+ name: :authn_requests_signed,
+ label: I18n.t("activemodel.attributes.saml/provider.authn_requests_signed"),
+ caption: I18n.t("saml.instructions.authn_requests_signed"),
+ disabled: provider.seeded_from_env?,
+ required: true
+ )
+ f.check_box(
+ name: :want_assertions_signed,
+ label: I18n.t("activemodel.attributes.saml/provider.want_assertions_signed"),
+ caption: I18n.t("saml.instructions.want_assertions_signed"),
+ disabled: provider.seeded_from_env?,
+ required: true
+ )
+ f.check_box(
+ name: :want_assertions_encrypted,
+ label: I18n.t("activemodel.attributes.saml/provider.want_assertions_encrypted"),
+ caption: I18n.t("saml.instructions.want_assertions_encrypted"),
+ disabled: provider.seeded_from_env?,
+ required: true
+ )
+ f.text_area(
+ name: :certificate,
+ rows: 10,
+ label: I18n.t("activemodel.attributes.saml/provider.certificate"),
+ caption: I18n.t("saml.instructions.certificate"),
+ required: false,
+ disabled: provider.seeded_from_env?,
+ input_width: :large
+ )
+ f.text_area(
+ name: :private_key,
+ rows: 10,
+ label: I18n.t("activemodel.attributes.saml/provider.private_key"),
+ caption: I18n.t("saml.instructions.private_key"),
+ required: false,
+ disabled: provider.seeded_from_env?,
+ input_width: :large
+ )
+ f.select_list(
+ name: :digest_method,
+ label: I18n.t("activemodel.attributes.saml/provider.digest_method"),
+ input_width: :large,
+ disabled: provider.seeded_from_env?,
+ caption: I18n.t("saml.instructions.digest_method", default_option: "SHA-1")
+ ) do |list|
+ Saml::Defaults::DIGEST_METHODS.each do |label, value|
+ list.option(label:, value:)
+ end
+ end
+ f.select_list(
+ name: :signature_method,
+ label: I18n.t("activemodel.attributes.saml/provider.signature_method"),
+ input_width: :large,
+ disabled: provider.seeded_from_env?,
+ caption: I18n.t("saml.instructions.signature_method", default_option: "RSA SHA-1")
+ ) do |list|
+ Saml::Defaults::SIGNATURE_METHODS.each do |label, value|
+ list.option(label:, value:)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/forms/saml/providers/mapping_form.rb b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb
new file mode 100644
index 000000000000..fa1be96a4795
--- /dev/null
+++ b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb
@@ -0,0 +1,81 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class MappingForm < BaseForm
+ form do |f|
+ f.text_area(
+ name: :mapping_login,
+ label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:login)),
+ caption: I18n.t("saml.instructions.mapping_login"),
+ required: true,
+ disabled: provider.seeded_from_env?,
+ rows: 8,
+ input_width: :large
+ )
+ f.text_area(
+ name: :mapping_mail,
+ label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:mail)),
+ caption: I18n.t("saml.instructions.mapping_mail"),
+ required: true,
+ disabled: provider.seeded_from_env?,
+ rows: 8,
+ input_width: :large
+ )
+ f.text_area(
+ name: :mapping_firstname,
+ label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:first_name)),
+ caption: I18n.t("saml.instructions.mapping_firstname"),
+ required: true,
+ disabled: provider.seeded_from_env?,
+ rows: 8,
+ input_width: :large
+ )
+ f.text_area(
+ name: :mapping_lastname,
+ label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:last_name)),
+ caption: I18n.t("saml.instructions.mapping_lastname"),
+ required: true,
+ disabled: provider.seeded_from_env?,
+ rows: 8,
+ input_width: :large
+ )
+ f.text_field(
+ name: :mapping_uid,
+ label: I18n.t("saml.providers.label_mapping_for", attribute: I18n.t("saml.providers.label_uid")),
+ caption: I18n.t("saml.instructions.mapping_uid"),
+ disabled: provider.seeded_from_env?,
+ rows: 8,
+ required: false,
+ input_width: :large
+ )
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/forms/saml/providers/metadata_options_form.rb b/modules/auth_saml/app/forms/saml/providers/metadata_options_form.rb
new file mode 100644
index 000000000000..1b2110105287
--- /dev/null
+++ b/modules/auth_saml/app/forms/saml/providers/metadata_options_form.rb
@@ -0,0 +1,69 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class MetadataOptionsForm < BaseForm
+ form do |f|
+ f.radio_button_group(
+ name: "metadata",
+ scope_name_to_model: false,
+ disabled: provider.seeded_from_env?,
+ label: I18n.t("saml.providers.label_metadata")
+ ) do |radio_group|
+ radio_group.radio_button(
+ value: "none",
+ checked: !@provider.has_metadata?,
+ label: I18n.t("saml.settings.metadata_none"),
+ caption: I18n.t("saml.instructions.metadata_none"),
+ disabled: provider.seeded_from_env?,
+ data: { "show-when-value-selected-target": "cause" }
+ )
+
+ radio_group.radio_button(
+ value: "url",
+ checked: @provider.metadata_url.present?,
+ label: I18n.t("saml.settings.metadata_url"),
+ caption: I18n.t("saml.instructions.metadata_url"),
+ disabled: provider.seeded_from_env?,
+ data: { "show-when-value-selected-target": "cause" }
+ )
+
+ radio_group.radio_button(
+ value: "xml",
+ checked: @provider.metadata_xml.present?,
+ label: I18n.t("saml.settings.metadata_xml"),
+ caption: I18n.t("saml.instructions.metadata_xml"),
+ disabled: provider.seeded_from_env?,
+ data: { "show-when-value-selected-target": "cause" }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb b/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb
new file mode 100644
index 000000000000..bfa8bfd57bfc
--- /dev/null
+++ b/modules/auth_saml/app/forms/saml/providers/metadata_url_form.rb
@@ -0,0 +1,44 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class MetadataUrlForm < BaseForm
+ form do |f|
+ f.text_field(
+ name: :metadata_url,
+ label: I18n.t("saml.settings.metadata_url"),
+ required: false,
+ disabled: provider.seeded_from_env?,
+ caption: I18n.t("saml.instructions.metadata_url"),
+ input_width: :xlarge
+ )
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/forms/saml/providers/metadata_xml_form.rb b/modules/auth_saml/app/forms/saml/providers/metadata_xml_form.rb
new file mode 100644
index 000000000000..75039598aff7
--- /dev/null
+++ b/modules/auth_saml/app/forms/saml/providers/metadata_xml_form.rb
@@ -0,0 +1,46 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class MetadataXmlForm < BaseForm
+ form do |f|
+ f.text_area(
+ name: :metadata_xml,
+ label: I18n.t("saml.settings.metadata_xml"),
+ caption: I18n.t("saml.instructions.metadata_xml"),
+ required: false,
+ disabled: provider.seeded_from_env?,
+ full_width: false,
+ rows: 10,
+ input_width: :medium
+ )
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/forms/saml/providers/name_input_form.rb b/modules/auth_saml/app/forms/saml/providers/name_input_form.rb
new file mode 100644
index 000000000000..4583f710e2cd
--- /dev/null
+++ b/modules/auth_saml/app/forms/saml/providers/name_input_form.rb
@@ -0,0 +1,44 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class NameInputForm < BaseForm
+ form do |f|
+ f.text_field(
+ name: :display_name,
+ label: I18n.t("activemodel.attributes.saml/provider.display_name"),
+ required: true,
+ disabled: provider.seeded_from_env?,
+ caption: I18n.t("saml.instructions.display_name"),
+ input_width: :medium
+ )
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/forms/saml/providers/request_attributes_form.rb b/modules/auth_saml/app/forms/saml/providers/request_attributes_form.rb
new file mode 100644
index 000000000000..e0b4cadcb9ed
--- /dev/null
+++ b/modules/auth_saml/app/forms/saml/providers/request_attributes_form.rb
@@ -0,0 +1,72 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class RequestAttributesForm < BaseForm
+ include Redmine::I18n
+
+ form do |f|
+ %i[login mail firstname lastname uid].each do |attribute|
+ f.group do |form_group|
+ uid = attribute == :uid
+ label = uid ? I18n.t("saml.providers.label_uid") : User.human_attribute_name(attribute)
+ form_group.text_field(
+ name: :"requested_#{attribute}_attribute",
+ label: I18n.t("saml.providers.label_requested_attribute_for", attribute: label),
+ required: !uid,
+ disabled: provider.seeded_from_env?,
+ caption: uid ? I18n.t("saml.instructions.request_uid") : nil,
+ input_width: :large
+ )
+
+ form_group.select_list(
+ name: :"requested_#{attribute}_format",
+ label: I18n.t("activemodel.attributes.saml/provider.format"),
+ input_width: :large,
+ disabled: provider.seeded_from_env?,
+ caption: link_translate(
+ "saml.instructions.documentation_link",
+ links: {
+ docs_url: ::OpenProject::Static::Links[:sysadmin_docs][:saml][:href]
+ },
+ target: "_blank"
+ )
+ ) do |list|
+ Saml::Defaults::ATTRIBUTE_FORMATS.each do |format|
+ list.option(label: format, value: format)
+ end
+ end
+ end
+
+ f.separator unless attribute == :uid
+ end
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb
new file mode 100644
index 000000000000..b7abab6cd845
--- /dev/null
+++ b/modules/auth_saml/app/forms/saml/providers/submit_or_cancel_form.rb
@@ -0,0 +1,86 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class SubmitOrCancelForm < ApplicationForm
+ form do |f|
+ if @state
+ f.hidden(
+ name: :edit_state,
+ scope_name_to_model: false,
+ value: @state
+ )
+ end
+
+ f.group(layout: :horizontal) do |button_group|
+ button_group.submit(**@submit_button_options) unless @provider.seeded_from_env?
+ button_group.button(**@cancel_button_options) unless @cancel_button_options[:hidden]
+ end
+ end
+
+ def initialize(provider:, state: nil, submit_button_options: {}, cancel_button_options: {})
+ super()
+ @state = state
+ @provider = provider
+ @submit_button_options = default_submit_button_options.merge(submit_button_options)
+ @cancel_button_options = default_cancel_button_options.merge(cancel_button_options)
+ end
+
+ private
+
+ def default_submit_button_options
+ {
+ name: :submit,
+ scheme: :primary,
+ label: I18n.t(:button_continue),
+ disabled: false
+ }
+ end
+
+ def default_cancel_button_options
+ {
+ name: :cancel,
+ scheme: :default,
+ tag: :a,
+ href: back_link,
+ data: { turbo: false },
+ label: I18n.t("button_cancel")
+ }
+ end
+
+ def back_link
+ if @provider.new_record?
+ OpenProject::StaticRouting::StaticRouter.new.url_helpers.saml_providers_path
+ else
+ OpenProject::StaticRouting::StaticRouter.new.url_helpers.saml_provider_path(@provider)
+ end
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/models/saml/provider.rb b/modules/auth_saml/app/models/saml/provider.rb
new file mode 100644
index 000000000000..3ae89b0fb726
--- /dev/null
+++ b/modules/auth_saml/app/models/saml/provider.rb
@@ -0,0 +1,120 @@
+module Saml
+ class Provider < AuthProvider
+ include HashBuilder
+
+ store_attribute :options, :icon, :string
+ store_attribute :options, :sp_entity_id, :string
+ store_attribute :options, :name_identifier_format, :string
+ store_attribute :options, :metadata_url, :string
+ store_attribute :options, :metadata_xml, :string
+ store_attribute :options, :last_metadata_update, :datetime
+
+ store_attribute :options, :idp_sso_service_url, :string
+ store_attribute :options, :idp_slo_service_url, :string
+
+ store_attribute :options, :idp_cert, :string
+ # Allow fallbcak to fingerprint from previous versions,
+ # but we do not offer this in the UI
+ store_attribute :options, :idp_cert_fingerprint, :string
+
+ store_attribute :options, :certificate, :string
+ store_attribute :options, :private_key, :string
+ store_attribute :options, :authn_requests_signed, :boolean
+ store_attribute :options, :want_assertions_signed, :boolean
+ store_attribute :options, :want_assertions_encrypted, :boolean
+ store_attribute :options, :digest_method, :string
+ store_attribute :options, :signature_method, :string
+
+ store_attribute :options, :mapping_login, :string
+ store_attribute :options, :mapping_mail, :string
+ store_attribute :options, :mapping_firstname, :string
+ store_attribute :options, :mapping_lastname, :string
+ store_attribute :options, :mapping_uid, :string
+
+ store_attribute :options, :requested_login_attribute, :string
+ store_attribute :options, :requested_mail_attribute, :string
+ store_attribute :options, :requested_firstname_attribute, :string
+ store_attribute :options, :requested_lastname_attribute, :string
+ store_attribute :options, :requested_uid_attribute, :string
+
+ store_attribute :options, :requested_login_format, :string
+ store_attribute :options, :requested_mail_format, :string
+ store_attribute :options, :requested_firstname_format, :string
+ store_attribute :options, :requested_lastname_format, :string
+ store_attribute :options, :requested_uid_format, :string
+
+ def self.slug_fragment = "saml"
+
+ def seeded_from_env?
+ (Setting.seed_saml_provider || {}).key?(slug)
+ end
+
+ def has_metadata?
+ metadata_xml.present? || metadata_url.present?
+ end
+
+ def metadata_updated?
+ metadata_xml_changed? || metadata_url_changed?
+ end
+
+ def metadata_endpoint
+ URI.join(auth_url, "metadata").to_s
+ end
+
+ def configured?
+ sp_entity_id.present? &&
+ idp_sso_service_url.present? &&
+ idp_certificate_configured?
+ end
+
+ def mapping_configured?
+ mapping_login.present? &&
+ mapping_mail.present? &&
+ mapping_firstname.present? &&
+ mapping_lastname.present?
+ end
+
+ def loaded_certificate
+ return nil if certificate.blank?
+
+ @loaded_certificate ||= OpenSSL::X509::Certificate.new(certificate)
+ end
+
+ def loaded_private_key
+ return nil if private_key.blank?
+
+ @loaded_private_key ||= OpenSSL::PKey::RSA.new(private_key)
+ end
+
+ def loaded_idp_certificates
+ return nil if idp_cert.blank?
+
+ @loaded_idp_certificates ||= OpenSSL::X509::Certificate.load(idp_cert)
+ end
+
+ def idp_certificate_configured?
+ idp_cert.present?
+ end
+
+ def idp_certificate_valid?
+ return false if idp_cert.blank?
+
+ !loaded_idp_certificates.all? { |cert| OneLogin::RubySaml::Utils.is_cert_expired(cert) }
+ end
+
+ def idp_cert=(cert)
+ formatted =
+ if cert.nil? || cert.include?("BEGIN CERTIFICATE")
+ cert
+ else
+ OneLogin::RubySaml::Utils.format_cert(cert)
+ end
+
+ super(formatted)
+ end
+
+ def assertion_consumer_service_url
+ callback_url
+ end
+ end
+end
diff --git a/modules/auth_saml/app/models/saml/provider/hash_builder.rb b/modules/auth_saml/app/models/saml/provider/hash_builder.rb
new file mode 100644
index 000000000000..926bdcee4ee4
--- /dev/null
+++ b/modules/auth_saml/app/models/saml/provider/hash_builder.rb
@@ -0,0 +1,84 @@
+module Saml
+ module Provider::HashBuilder
+ def formatted_attribute_statements
+ {
+ email: split_attribute_mapping(mapping_mail),
+ login: split_attribute_mapping(mapping_login),
+ first_name: split_attribute_mapping(mapping_firstname),
+ last_name: split_attribute_mapping(mapping_lastname),
+ uid: split_attribute_mapping(mapping_uid)
+ }.compact
+ end
+
+ def split_attribute_mapping(mapping)
+ return if mapping.blank?
+
+ mapping.split(/\s*\R+\s*/)
+ end
+
+ def formatted_request_attributes
+ [
+ { name: requested_login_attribute, name_format: requested_login_format, friendly_name: "Login" },
+ { name: requested_mail_attribute, name_format: requested_mail_format, friendly_name: "Email" },
+ { name: requested_firstname_attribute, name_format: requested_firstname_format, friendly_name: "First Name" },
+ { name: requested_lastname_attribute, name_format: requested_lastname_format, friendly_name: "Last Name" }
+ ]
+ end
+
+ def idp_cert_options_hash
+ if idp_cert_fingerprint.present?
+ return { idp_cert_fingerprint: }
+ end
+
+ if idp_cert.present?
+ certificates = loaded_idp_certificates.map(&:to_pem)
+ if certificates.count > 1
+ {
+ idp_cert_multi: {
+ signing: certificates,
+ encryption: certificates
+ }
+ }
+ else
+ { idp_cert: certificates.first }
+ end
+ else
+ {}
+ end
+ end
+
+ def security_options_hash
+ {
+ check_idp_cert_expiration: false, # done in contract
+ check_sp_cert_expiration: false, # done in contract
+ metadata_signed: certificate.present? && private_key.present?,
+ authn_requests_signed: !!authn_requests_signed,
+ want_assertions_signed: !!want_assertions_signed,
+ want_assertions_encrypted: !!want_assertions_encrypted,
+ digest_method:,
+ signature_method:
+ }.compact
+ end
+
+ def to_h # rubocop:disable Metrics/AbcSize
+ {
+ name: slug,
+ display_name:,
+ icon:,
+ assertion_consumer_service_url:,
+ sp_entity_id:,
+ idp_sso_service_url:,
+ idp_slo_service_url:,
+ name_identifier_format:,
+ certificate:,
+ private_key:,
+ attribute_statements: formatted_attribute_statements,
+ request_attributes: formatted_request_attributes,
+ uid_attribute: mapping_uid
+ }
+ .merge(idp_cert_options_hash)
+ .merge(security: security_options_hash)
+ .compact
+ end
+ end
+end
diff --git a/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb b/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb
new file mode 100644
index 000000000000..134ed8a94ffe
--- /dev/null
+++ b/modules/auth_saml/app/seeders/env_data/saml/provider_seeder.rb
@@ -0,0 +1,82 @@
+#-- 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.
+module EnvData
+ module Saml
+ class ProviderSeeder < Seeder
+ def seed_data!
+ provider_configuration.each do |name, options|
+ print_status " ↳ Creating or Updating SAML provider #{name}" do
+ call = ::Saml::SyncService.new(name, options).call
+
+ if call.success
+ print_status " - #{call.message}"
+ else
+ raise call.message
+ end
+ end
+ end
+ end
+
+ def applicable?
+ provider_configuration.present?
+ end
+
+ def provider_configuration
+ config = Setting.seed_saml_provider
+ deprecated_config = load_deprecated_configuration.presence || {}
+
+ config.reverse_merge(deprecated_config)
+ end
+
+ private
+
+ def load_deprecated_configuration
+ deprecated_settings = Rails.root.join("config/plugins/auth_saml/settings.yml")
+
+ if deprecated_settings.exist?
+ Rails.logger.info do
+ <<~WARNING
+ Loading SAML configuration from deprecated location #{deprecated_settings}.
+ Please use ENV variables or UI configuration instead.
+
+ For more information, see our guide on how to configure SAML.
+ https://www.openproject.org/docs/system-admin-guide/authentication/saml/
+ WARNING
+ end
+
+ begin
+ YAML::load(File.open(deprecated_settings))&.symbolize_keys
+ rescue StandardError
+ Rails.logger.error "Failed to load deprecated SAML configuration from #{deprecated_settings}. Ignoring that file."
+ nil
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/services/saml/configuration_mapper.rb b/modules/auth_saml/app/services/saml/configuration_mapper.rb
new file mode 100644
index 000000000000..748745546989
--- /dev/null
+++ b/modules/auth_saml/app/services/saml/configuration_mapper.rb
@@ -0,0 +1,95 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ class ConfigurationMapper
+ attr_reader :configuration
+
+ def initialize(configuration)
+ @configuration = configuration
+ end
+
+ def call!
+ options = mapped_options(configuration.deep_stringify_keys)
+ {
+ "options" => options,
+ "slug" => options.delete("name"),
+ "display_name" => options.delete("display_name") || "SAML"
+ }
+ end
+
+ private
+
+ def mapped_options(options)
+ options["idp_sso_service_url"] ||= options.delete("idp_sso_target_url")
+ options["idp_slo_service_url"] ||= options.delete("idp_slo_target_url")
+ options["sp_entity_id"] ||= options.delete("issuer")
+
+ build_idp_cert(options)
+ extract_security_options(options)
+ extract_mapping(options)
+
+ options.compact
+ end
+
+ def extract_mapping(options)
+ return unless options["attribute_statements"]
+
+ options["mapping_login"] = extract_mapping_attribute(options, "login")
+ options["mapping_mail"] = extract_mapping_attribute(options, "email")
+ options["mapping_firstname"] = extract_mapping_attribute(options, "first_name")
+ options["mapping_lastname"] = extract_mapping_attribute(options, "last_name")
+ options["mapping_uid"] = extract_mapping_attribute(options, "uid")
+ end
+
+ def extract_mapping_attribute(options, key)
+ value = options["attribute_statements"][key]
+
+ if value.present?
+ Array(value).join("\n")
+ end
+ end
+
+ def build_idp_cert(options)
+ if options["idp_cert"]
+ options["idp_cert"] = OneLogin::RubySaml::Utils.format_cert(options["idp_cert"])
+ elsif options["idp_cert_multi"]
+ options["idp_cert"] = options["idp_cert_multi"]["signing"]
+ .map { |cert| OneLogin::RubySaml::Utils.format_cert(cert) }
+ .join("\n")
+ end
+ end
+
+ def extract_security_options(options)
+ return unless options["security"]
+
+ options.merge! options["security"].slice("authn_requests_signed", "want_assertions_signed",
+ "want_assertions_encrypted", "digest_method", "signature_method")
+ end
+ end
+end
diff --git a/modules/auth_saml/app/services/saml/providers/create_service.rb b/modules/auth_saml/app/services/saml/providers/create_service.rb
new file mode 100644
index 000000000000..9f36558b94be
--- /dev/null
+++ b/modules/auth_saml/app/services/saml/providers/create_service.rb
@@ -0,0 +1,35 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class CreateService < BaseServices::Create
+ include UpdateMetadata
+ end
+ end
+end
diff --git a/modules/auth_saml/app/services/saml/providers/delete_service.rb b/modules/auth_saml/app/services/saml/providers/delete_service.rb
new file mode 100644
index 000000000000..ea5bae1a4454
--- /dev/null
+++ b/modules/auth_saml/app/services/saml/providers/delete_service.rb
@@ -0,0 +1,33 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+module Saml
+ module Providers
+ class DeleteService < BaseServices::Delete
+ end
+ end
+end
diff --git a/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb
new file mode 100644
index 000000000000..2481c4c2964a
--- /dev/null
+++ b/modules/auth_saml/app/services/saml/providers/set_attributes_service.rb
@@ -0,0 +1,151 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class SetAttributesService < BaseServices::SetAttributes
+ private
+
+ def set_attributes(params)
+ update_options(params.delete(:options)) if params.key?(:options)
+
+ super
+
+ update_available_state
+ end
+
+ def update_available_state
+ model.change_by_system do
+ model.available = model.configured? && model.mapping_configured?
+ end
+ end
+
+ def update_options(options) # rubocop:disable Metrics/AbcSize
+ update_idp_cert(options.delete(:idp_cert)) if options.key?(:idp_cert)
+ update_certificate(options.delete(:certificate)) if options.key?(:certificate)
+ update_private_key(options.delete(:private_key)) if options.key?(:private_key)
+ update_mapping(options)
+
+ options
+ .select { |key, _| Saml::Provider.stored_attributes[:options].include?(key.to_s) }
+ .each do |key, value|
+ model.public_send(:"#{key}=", value)
+ end
+ end
+
+ def set_default_attributes(*)
+ model.change_by_system do
+ set_slug
+ set_default_creator
+ set_default_mapping
+ set_default_requested_attributes
+ set_issuer
+ set_name_identifier_format
+ set_default_digest
+ set_default_encryption
+ end
+ end
+
+ def set_default_encryption
+ model.authn_requests_signed = false if model.authn_requests_signed.nil?
+ model.want_assertions_signed = false if model.want_assertions_signed.nil?
+ model.want_assertions_encrypted = false if model.want_assertions_encrypted.nil?
+ end
+
+ def set_slug
+ model.slug ||= "#{model.class.slug_fragment}-#{model.display_name.to_url}" if model.display_name
+ end
+
+ def set_default_digest
+ model.signature_method ||= Saml::Defaults::SIGNATURE_METHODS["RSA SHA-1"]
+ model.digest_method ||= Saml::Defaults::DIGEST_METHODS["SHA-1"]
+ end
+
+ def set_name_identifier_format
+ model.name_identifier_format ||= Saml::Defaults::NAME_IDENTIFIER_FORMAT
+ end
+
+ def set_default_creator
+ model.creator ||= user
+ end
+
+ def update_idp_cert(cert)
+ model.idp_cert = cert
+ end
+
+ def update_certificate(cert)
+ model.certificate = OneLogin::RubySaml::Utils.format_cert(cert)
+ end
+
+ def update_private_key(private_key)
+ model.private_key = OneLogin::RubySaml::Utils.format_private_key(private_key)
+ end
+
+ ##
+ # Clean up provided mapping, reducing whitespace
+ def update_mapping(params)
+ %i[mapping_mail mapping_login mapping_firstname mapping_lastname mapping_uid].each do |attr|
+ next unless params.key?(attr)
+
+ parsed = params.delete(attr)
+ .gsub("\r\n", "\n")
+ .gsub!(/^\s*(.+?)\s*$/, '\1')
+
+ model.public_send(:"#{attr}=", parsed)
+ end
+ end
+
+ def set_default_mapping
+ model.mapping_login ||= Saml::Defaults::MAIL_MAPPING
+ model.mapping_mail ||= Saml::Defaults::MAIL_MAPPING
+ model.mapping_firstname ||= Saml::Defaults::FIRSTNAME_MAPPING
+ model.mapping_lastname ||= Saml::Defaults::LASTNAME_MAPPING
+ end
+
+ def set_default_requested_attributes # rubocop:disable Metrics/AbcSize,Metrics/PerceivedComplexity
+ model.requested_login_attribute ||= first_mapping(Saml::Defaults::MAIL_MAPPING)
+ model.requested_mail_attribute ||= first_mapping(Saml::Defaults::MAIL_MAPPING)
+ model.requested_firstname_attribute ||= first_mapping(Saml::Defaults::FIRSTNAME_MAPPING)
+ model.requested_lastname_attribute ||= first_mapping(Saml::Defaults::LASTNAME_MAPPING)
+
+ model.requested_login_format ||= Saml::Defaults::ATTRIBUTE_FORMATS.first
+ model.requested_mail_format ||= Saml::Defaults::ATTRIBUTE_FORMATS.first
+ model.requested_firstname_format ||= Saml::Defaults::ATTRIBUTE_FORMATS.first
+ model.requested_lastname_format ||= Saml::Defaults::ATTRIBUTE_FORMATS.first
+ end
+
+ def first_mapping(mapping)
+ mapping.split("\n").first
+ end
+
+ def set_issuer
+ model.sp_entity_id ||= OpenProject::StaticRouting::StaticUrlHelpers.new.root_url
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/services/saml/providers/update_metadata.rb b/modules/auth_saml/app/services/saml/providers/update_metadata.rb
new file mode 100644
index 000000000000..8b24d003d494
--- /dev/null
+++ b/modules/auth_saml/app/services/saml/providers/update_metadata.rb
@@ -0,0 +1,48 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ module UpdateMetadata
+ def after_validate(_params, call)
+ model = call.result
+ return call unless model&.metadata_updated?
+
+ metadata_update_call(call.result)
+ end
+
+ private
+
+ def metadata_update_call(provider)
+ Saml::UpdateMetadataService
+ .new(provider:, user:)
+ .call
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/app/services/saml/providers/update_service.rb b/modules/auth_saml/app/services/saml/providers/update_service.rb
new file mode 100644
index 000000000000..97f5fc835646
--- /dev/null
+++ b/modules/auth_saml/app/services/saml/providers/update_service.rb
@@ -0,0 +1,35 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ module Providers
+ class UpdateService < BaseServices::Update
+ include UpdateMetadata
+ end
+ end
+end
diff --git a/modules/auth_saml/app/services/saml/sync_service.rb b/modules/auth_saml/app/services/saml/sync_service.rb
new file mode 100644
index 000000000000..ed7217fb8d6c
--- /dev/null
+++ b/modules/auth_saml/app/services/saml/sync_service.rb
@@ -0,0 +1,69 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ ##
+ # Synchronize a configuration from ENV or legacy settings to a SAML provider record
+ class SyncService
+ attr_reader :name, :configuration
+
+ def initialize(name, configuration)
+ @name = name
+ @configuration = configuration
+ end
+
+ def call
+ params = ::Saml::ConfigurationMapper.new(configuration).call!
+ provider = ::Saml::Provider.find_by(slug: name)
+
+ if provider
+ update(name, provider, params)
+ else
+ create(name, params)
+ end
+ end
+
+ private
+
+ def create(name, params)
+ ::Saml::Providers::CreateService
+ .new(user: User.system)
+ .call(params)
+ .on_success { |call| call.message = "Successfully saved SAML provider #{name}." }
+ .on_failure { |call| call.message = "Failed to create SAML provider: #{call.message}" }
+ end
+
+ def update(name, provider, params)
+ ::Saml::Providers::UpdateService
+ .new(model: provider, user: User.system)
+ .call(params)
+ .on_success { |call| call.message = "Successfully updated SAML provider #{name}." }
+ .on_failure { |call| call.message = "Failed to update SAML provider: #{call.message}" }
+ end
+ end
+end
diff --git a/modules/auth_saml/app/services/saml/update_metadata_service.rb b/modules/auth_saml/app/services/saml/update_metadata_service.rb
new file mode 100644
index 000000000000..6f0976aef0b5
--- /dev/null
+++ b/modules/auth_saml/app/services/saml/update_metadata_service.rb
@@ -0,0 +1,79 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2024 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.
+#++
+
+module Saml
+ class UpdateMetadataService
+ attr_reader :user, :provider
+
+ def initialize(user:, provider:)
+ @user = user
+ @provider = provider
+ end
+
+ def call
+ apply_metadata(fetch_metadata)
+ rescue StandardError => e
+ OpenProject.logger.error(e)
+ ServiceResult.failure(result: provider,
+ message: I18n.t("saml.metadata_parser.error", error: e.class.name))
+ end
+
+ 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: })
+ end
+
+ def fetch_metadata
+ if provider.metadata_url.present?
+ parse_url
+ elsif provider.metadata_xml.present?
+ parse_xml
+ else
+ {}
+ end
+ end
+
+ def parse_xml
+ parser_instance.parse_to_hash(provider.metadata_xml)
+ end
+
+ def parse_url
+ parser_instance.parse_remote_to_hash(provider.metadata_url)
+ end
+
+ def parser_instance
+ OneLogin::RubySaml::IdpMetadataParser.new
+ end
+ end
+end
diff --git a/modules/auth_saml/app/views/saml/providers/_form.html.erb b/modules/auth_saml/app/views/saml/providers/_form.html.erb
new file mode 100644
index 000000000000..2573404127b6
--- /dev/null
+++ b/modules/auth_saml/app/views/saml/providers/_form.html.erb
@@ -0,0 +1,110 @@
+
+
+
+
+
diff --git a/modules/auth_saml/app/views/saml/providers/edit.html.erb b/modules/auth_saml/app/views/saml/providers/edit.html.erb
new file mode 100644
index 000000000000..09f47ebefb2d
--- /dev/null
+++ b/modules/auth_saml/app/views/saml/providers/edit.html.erb
@@ -0,0 +1,26 @@
+<% page_title = t('saml.providers.label_edit', name: @provider.display_name) %>
+
+<% html_title(t(:label_administration), page_title) -%>
+
+<%= render(Primer::OpenProject::PageHeader.new) do |header| %>
+ <% header.with_title { @provider.display_name } %>
+
+ <% header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration") },
+ { href: saml_providers_path, text: t('saml.providers.plural') },
+ @provider.display_name]) %>
+
+ <% header.with_description do %>
+ <%= link_translate(
+ 'saml.instructions.documentation_link',
+ links: {
+ docs_url: ::OpenProject::Static::Links[:sysadmin_docs][:saml][:href],
+ },
+ target: "_blank"
+ ) %>
+ <% end %>
+<% end %>
+
+<%= render(Saml::Providers::ViewComponent.new(@provider,
+ view_mode: :edit,
+ edit_mode: @edit_mode,
+ edit_state: @edit_state)) %>
diff --git a/modules/auth_saml/app/views/saml/providers/index.html.erb b/modules/auth_saml/app/views/saml/providers/index.html.erb
new file mode 100644
index 000000000000..16c1bcfb4ef5
--- /dev/null
+++ b/modules/auth_saml/app/views/saml/providers/index.html.erb
@@ -0,0 +1,23 @@
+<% html_title t(:label_administration), t('saml.providers.plural') %>
+
+<%= content_for :content_header do %>
+ <%= render(Primer::OpenProject::PageHeader.new) do |header|
+ header.with_title { t('saml.providers.plural') }
+ header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration") },
+ t('saml.providers.plural')])
+ end %>
+
+ <%= render(Primer::OpenProject::SubHeader.new) do |subheader|
+ subheader.with_action_button(scheme: :primary,
+ aria: { label: I18n.t('saml.providers.label_add_new') },
+ title: I18n.t('saml.providers.label_add_new'),
+ tag: :a,
+ href: new_saml_provider_path) do |button|
+ button.with_leading_visual_icon(icon: :plus)
+ t('saml.providers.singular')
+ end
+ end
+ %>
+<% end %>
+
+<%= render ::Saml::Providers::TableComponent.new(rows: @providers) %>
diff --git a/modules/auth_saml/app/views/saml/providers/new.html.erb b/modules/auth_saml/app/views/saml/providers/new.html.erb
new file mode 100644
index 000000000000..8c66e670f24e
--- /dev/null
+++ b/modules/auth_saml/app/views/saml/providers/new.html.erb
@@ -0,0 +1,21 @@
+<% html_title(t(:label_administration), t('saml.providers.label_add_new')) -%>
+
+<%= render(Primer::OpenProject::PageHeader.new) do |header| %>
+ <% header.with_title { t('saml.providers.label_add_new') } %>
+
+ <% header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration")},
+ { href: saml_providers_path, text: t('saml.providers.plural') },
+ t('saml.providers.label_add_new')]) %>
+
+ <% header.with_description do %>
+ <%= link_translate(
+ 'saml.instructions.documentation_link',
+ links: {
+ docs_url: ::OpenProject::Static::Links[:sysadmin_docs][:saml][:href],
+ },
+ target: "_blank"
+ ) %>
+ <% end %>
+<% end %>
+
+<%= render(Saml::Providers::ViewComponent.new(@provider, edit_mode: true, edit_state: :name)) %>
diff --git a/modules/auth_saml/app/views/saml/providers/show.html.erb b/modules/auth_saml/app/views/saml/providers/show.html.erb
new file mode 100644
index 000000000000..64d09eaad670
--- /dev/null
+++ b/modules/auth_saml/app/views/saml/providers/show.html.erb
@@ -0,0 +1,42 @@
+<% html_title(t(:label_administration), t('saml.providers.plural'), @provider.display_name) -%>
+
+<%= render(Primer::OpenProject::PageHeader.new) do |header| %>
+ <% header.with_title { @provider.display_name } %>
+
+ <% header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration") },
+ { href: saml_providers_path, text: t('saml.providers.plural') },
+ @provider.display_name]) %>
+
+ <%
+ header.with_action_button(
+ tag: :a,
+ scheme: :danger,
+ mobile_icon: :trash,
+ mobile_label: t(:button_delete),
+ size: :medium,
+ href: saml_provider_path(@provider),
+ aria: { label: I18n.t(:button_delete) },
+ data: {
+ confirm: t(:text_are_you_sure),
+ method: :delete,
+ },
+ title: I18n.t(:button_delete)
+ ) do |button|
+ button.with_leading_visual_icon(icon: :trash)
+ t(:button_delete)
+ end
+ %>
+
+<% end %>
+
+<%=
+ render(Primer::Alpha::Layout.new(stacking_breakpoint: :md)) do |content|
+ content.with_main do
+ render Saml::Providers::ViewComponent.new(@provider, view_mode: :show)
+ end
+
+ content.with_sidebar(row_placement: :start, col_placement: :end) do
+ render Saml::Providers::SidePanelComponent.new(@provider)
+ end
+ end
+%>
diff --git a/modules/auth_saml/app/views/saml/providers/upsale.html.erb b/modules/auth_saml/app/views/saml/providers/upsale.html.erb
new file mode 100644
index 000000000000..5a3b6742c1d2
--- /dev/null
+++ b/modules/auth_saml/app/views/saml/providers/upsale.html.erb
@@ -0,0 +1,9 @@
+<% html_title(t(:label_administration), t('saml.providers.plural')) -%>
+
+<%= render template: 'common/upsale',
+ locals: {
+ feature_title: t('saml.providers.plural'),
+ feature_description: t('saml.providers.upsale.description'),
+ feature_reference: 'enterprise-openid-connect',
+ feature_image: 'enterprise/open-id-providers.jpg' # TODO
+ } %>
diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml
new file mode 100644
index 000000000000..3ca767a5a951
--- /dev/null
+++ b/modules/auth_saml/config/locales/en.yml
@@ -0,0 +1,163 @@
+en:
+ activemodel:
+ attributes:
+ saml/provider:
+ display_name: Name
+ identifier: Identifier
+ secret: Secret
+ scope: Scope
+ assertion_consumer_service_url: ACS (Assertion consumer service) URL
+ limit_self_registration: Limit self registration
+ sp_entity_id: Service entity ID
+ metadata_url: Identity provider metadata URL
+ name_identifier_format: Name identifier format
+ idp_sso_service_url: Identity provider login endpoint
+ idp_slo_service_url: Identity provider logout endpoint
+ idp_cert: Public certificate of identity provider
+ authn_requests_signed: Sign SAML AuthnRequests
+ want_assertions_signed: Require signed responses
+ want_assertions_encrypted: Require encrypted responses
+ certificate: Certificate used by OpenProject for SAML requests
+ private_key: Corresponding private key for OpenProject SAML requests
+ signature_method: Signature algorithm
+ digest_method: Digest algorithm
+ format: "Format"
+ icon: "Custom icon"
+ activerecord:
+ errors:
+ models:
+ saml/provider:
+ invalid_certificate: "is not a valid PEM-formatted certificate: %{additional_message}"
+ invalid_private_key: "is not a valid PEM-formatted private key: %{additional_message}"
+ certificate_expired: "is expired and can no longer be used."
+ unmatched_private_key: "does not belong to the given certificate"
+ saml:
+ menu_title: SAML providers
+ info:
+ title: "SAML Protocol Configuration Parameters"
+ description: >
+ Use these parameters to configure your identity provider connection to OpenProject.
+ metadata_parser:
+ success: "Successfully updated the configuration using the identity provider metadata."
+ invalid_url: "Provided metadata URL is invalid. Provide a HTTP(s) URL."
+ error: "Failed to retrieve the identity provider metadata: %{error}"
+ providers:
+ label_empty_title: "No SAML providers configured yet."
+ label_empty_description: "Add a provider to see them here."
+ label_automatic_configuration: Automatic configuration
+ label_metadata: Metadata
+ label_metadata_endpoint: OpenProject metadata endpoint
+ label_openproject_information: OpenProject information
+ label_configuration_details: "Identity provider endpoints and certificates"
+ label_configuration_encryption: "Signatures and Encryption"
+ label_add_new: New SAML identity provider
+ label_edit: Edit SAML identity provider %{name}
+ label_uid: Internal user id
+ label_mapping: Mapping
+ label_mapping_for: "Mapping for: %{attribute}"
+ label_requested_attribute_for: "Requested attribute for: %{attribute}"
+ no_results_table: No SAML identity providers have been defined yet.
+ plural: SAML identity providers
+ singular: SAML identity provider
+ requested_attributes: Requested attributes
+ attribute_mapping: Attribute mapping
+ attribute_mapping_text: >
+ The following fields control which attributes provided by the SAML identity provider
+ are used to provide user attributes in OpenProject
+ metadata:
+ dialog: "This is the URL where the OpenProject SAML metadata is available. Optionally use it to configure your identity provider:"
+ upsale:
+ description: Connect OpenProject to a SAML identity provider
+ request_attributes:
+ title: 'Requested attributes'
+ legend: >
+ These attributes are added to the SAML XML metadata to signify to the identify provider which attributes OpenProject requires.
+ You may still need to explicitly configure your identity provider to send these attributes. Please refer to your IdP's documentation.
+ name: 'Requested attribute key'
+ format: 'Attribute format'
+ section_headers:
+ configuration: "Primary configuration"
+ attributes: "Attributes"
+ section_texts:
+ display_name: "Configure the display name of the SAML provider."
+ metadata: "Pre-fill configuration using a metadata URL or by pasting metadata XML"
+ metadata_form: "If your identity provider has a metadata endpoint or XML download, add it below to pre-fill the configuration."
+ metadata_form_banner: "Editing the metadata may override existing values in other sections. "
+ configuration: "Configure the endpoint URLs for the identity provider, certificates, and further SAML options."
+ configuration_metadata: "This information has been pre-filled using the supplied metadata. In most cases, they do not require editing."
+ encryption: "Configure assertion signatures and encryption for SAML requests and responses."
+ encryption_form: "You may optionally want to encrypt the assertion response, or have requests from OpenProject signed."
+ mapping: "Manually adjust the mapping between the SAML response and user attributes in OpenProject."
+ 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."
+ settings:
+ metadata_none: "I don't have metadata"
+ metadata_url: "Metadata URL"
+ metadata_xml: "Metadata XML"
+ instructions:
+ documentation_link: >
+ Please refer to our [documentation on configuring SAML providers](docs_url) for more information on these configuration options.
+ display_name: >
+ The name of the provider. This will be displayed as the login button and in the list of providers.
+ metadata_none: >
+ Your identity provider does not have a metadata endpoint or XML download option. You can the configuration manually.
+ metadata_url: >
+ Your identity provider provides a metadata URL.
+ metadata_xml: >
+ Your identity provider provides a metadata XML download.
+ limit_self_registration: >
+ If enabled users can only register using this provider if the self registration setting allows for it.
+ sp_entity_id: >
+ The entity ID of the service provider (SP). Sometimes also referred to as Audience. This is the unique client identifier of the OpenProject instance.
+ idp_sso_service_url: >
+ The URL of the identity provider login endpoint.
+ idp_slo_service_url: >
+ The URL of the identity provider login endpoint.
+ idp_cert: >
+ Enter the X509 PEM-formatted public certificate of the identity provider.
+ You can enter multiple certificates by separating them with a newline.
+ name_identifier_format: >
+ Set the name identifier format to be used for the SAML assertion.
+ sp_metadata_endpoint: >
+ This is the URL where the OpenProject SAML metadata is available.
+ Optionally use it to configure your identity provider.
+ mapping: >
+ Configure the mapping between the SAML response and user attributes in OpenProject.
+ You can configure multiple attribute names to look for. OpenProject will choose the first available attribute
+ from the SAML response.
+ mapping_login: >
+ SAML attributes from the response used for the login.
+ mapping_mail: >
+ SAML attributes from the response used for the email of the user.
+ mapping_firstname: >
+ SAML attributes from the response used for the given name.
+ mapping_lastname: >
+ SAML attributes from the response used for the last name.
+ mapping_uid: >
+ SAML attribute to use for the internal user ID. Leave empty to use the name_id attribute instead
+ request_uid: >
+ SAML attribute to request for the internal user ID. By default, the name_id will be used for this field.
+ requested_attributes: >
+ These attributes are added to the SAML request XML to communicate to the identity provider which attributes OpenProject requires.
+ requested_format: >
+ The format of the requested attribute. This is used to specify the format of the attribute in the SAML request.
+ Please see [documentation on configuring requested attributes](docs_url) for more information.
+ authn_requests_signed: >
+ If checked, OpenProject will sign the SAML AuthnRequest. You will have to provide a signing certificate and private key using the fields below.
+ want_assertions_signed: >
+ If checked, OpenProject will required signed responses from the identity provider using it's own certificate keypair.
+ OpenProject will verify the signature against the certificate from the basic configuration section.
+ want_assertions_encrypted: >
+ If enabled, require the identity provider to encrypt the assertion response using the certificate pair that you provide.
+ certificate: >
+ Enter the X509 PEM-formatted certificate used by OpenProject for signing SAML requests.
+ private_key: >
+ Enter the X509 PEM-formatted private key for the above certificate. This needs to be an RSA private key.
+ signature_method: >
+ Select the signature algorithm to use for the SAML request signature performed by OpenProject (Default: %{default_option}).
+ digest_method: >
+ Select the digest algorithm to use for the SAML request signature performed by OpenProject (Default: %{default_option}).
+ icon: >
+ Optionally provide a public URL to an icon graphic that will be displayed next to the provider name.
+ metadata_for_idp: >
+ This information might be requested by your SAML identity provider.
diff --git a/modules/auth_saml/config/routes.rb b/modules/auth_saml/config/routes.rb
new file mode 100644
index 000000000000..926f2427ae98
--- /dev/null
+++ b/modules/auth_saml/config/routes.rb
@@ -0,0 +1,11 @@
+Rails.application.routes.draw do
+ scope :admin do
+ namespace :saml do
+ resources :providers do
+ member do
+ post :import_metadata
+ end
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/db/migrate/20240821121856_migrate_saml_settings_to_providers.rb b/modules/auth_saml/db/migrate/20240821121856_migrate_saml_settings_to_providers.rb
new file mode 100644
index 000000000000..ae82aba4de29
--- /dev/null
+++ b/modules/auth_saml/db/migrate/20240821121856_migrate_saml_settings_to_providers.rb
@@ -0,0 +1,43 @@
+class MigrateSamlSettingsToProviders < ActiveRecord::Migration[7.1]
+ def up
+ providers = Hash(Setting.plugin_openproject_auth_saml).with_indifferent_access[:providers]
+ return if providers.blank?
+
+ providers.each do |name, options|
+ migrate_provider!(name, options)
+ end
+ end
+
+ def down
+ # This migration does not yet remove Setting.plugin_openproject_auth_saml
+ # so it can be retried.
+ end
+
+ private
+
+ def migrate_provider!(name, options)
+ puts "Trying to migrate SAML provider #{name} from previous settings format..."
+ call = ::Saml::SyncService.new(name, options).call
+
+ if call.success
+ puts <<~SUCCESS
+ Successfully migrated SAML provider #{name} from previous settings format.
+ You can now manage this provider in the new administrative UI within OpenProject under
+ the "Administration -> Authentication -> SAML providers" section.
+ SUCCESS
+ else
+ raise <<~ERROR
+ Failed to create or update SAML provider #{name} from previous settings format.
+ The error message was: #{call.message}
+
+ Please check the logs for more information and open a bug report in our community:
+ https://www.openproject.org/docs/development/report-a-bug/
+
+ If you would like to skip migrating the SAML setting and discard them instead, you can use our documentation
+ to unset any previous SAML settings:
+
+ https://www.openproject.org/docs/system-admin-guide/authentication/saml/
+ ERROR
+ end
+ end
+end
diff --git a/modules/auth_saml/lib/open_project/auth_saml/engine.rb b/modules/auth_saml/lib/open_project/auth_saml/engine.rb
index 09f680d1cc32..455ac439112d 100644
--- a/modules/auth_saml/lib/open_project/auth_saml/engine.rb
+++ b/modules/auth_saml/lib/open_project/auth_saml/engine.rb
@@ -2,42 +2,12 @@
module OpenProject
module AuthSaml
def self.configuration
- RequestStore.fetch(:openproject_omniauth_saml_provider) do
- @saml_settings ||= load_global_settings!
- @saml_settings.deep_merge(settings_from_db)
- end
- end
-
- def self.reload_configuration!
- @saml_settings = nil
- RequestStore.delete :openproject_omniauth_saml_provider
- end
-
- ##
- # Loads the settings once to avoid accessing the file in each request
- def self.load_global_settings!
- Hash(settings_from_config || settings_from_yaml).with_indifferent_access
- end
-
- def self.settings_from_db
- value = Hash(Setting.plugin_openproject_auth_saml).with_indifferent_access[:providers]
-
- value.is_a?(Hash) ? value : {}
- end
-
- def self.settings_from_config
- if OpenProject::Configuration["saml"].present?
- Rails.logger.info("[auth_saml] Registering saml integration from configuration.yml")
-
- OpenProject::Configuration["saml"]
- end
- end
-
- def self.settings_from_yaml
- if (settings = Rails.root.join("config/plugins/auth_saml/settings.yml")).exist?
- Rails.logger.info("[auth_saml] Registering saml integration from settings file")
+ providers = Saml::Provider.where(available: true)
- YAML::load(File.open(settings)).symbolize_keys
+ OpenProject::Cache.fetch(providers.cache_key) do
+ providers.each_with_object({}) do |provider, hash|
+ hash[provider.slug.to_sym] = provider.to_h
+ end
end
end
@@ -50,7 +20,14 @@ class Engine < ::Rails::Engine
register "openproject-auth_saml",
author_url: "https://github.com/finnlabs/openproject-auth_saml",
bundled: true,
- settings: { default: { "providers" => nil } }
+ settings: { default: { "providers" => nil } } do
+ menu :admin_menu,
+ :plugin_saml,
+ :saml_providers_path,
+ parent: :authentication,
+ caption: ->(*) { I18n.t("saml.menu_title") },
+ enterprise_feature: "sso_auth_providers"
+ end
assets %w(
auth_saml/**
@@ -82,10 +59,12 @@ class Engine < ::Rails::Engine
end
initializer "auth_saml.configuration" do
- ::Settings::Definition.add "saml",
- default: nil,
- format: :hash,
- writable: false
+ ::Settings::Definition.add :seed_saml_provider,
+ description: "Provide a SAML provider and sync its settings through ENV",
+ env_alias: "OPENPROJECT_SAML",
+ writable: false,
+ default: {},
+ format: :hash
end
end
end
diff --git a/modules/auth_saml/spec/contracts/saml/providers/create_contract_spec.rb b/modules/auth_saml/spec/contracts/saml/providers/create_contract_spec.rb
new file mode 100644
index 000000000000..81f4b0a13294
--- /dev/null
+++ b/modules/auth_saml/spec/contracts/saml/providers/create_contract_spec.rb
@@ -0,0 +1,49 @@
+#-- 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 "contracts/shared/model_contract_shared_context"
+
+RSpec.describe Saml::Providers::CreateContract do
+ include_context "ModelContract shared context"
+
+ let(:provider) { build(:saml_provider) }
+ let(:contract) { described_class.new provider, current_user }
+
+ context "when admin" do
+ let(:current_user) { build_stubbed(:admin) }
+
+ it_behaves_like "contract is valid"
+ end
+
+ context "when non-admin" do
+ let(:current_user) { build_stubbed(:user) }
+
+ it_behaves_like "contract is invalid", base: :error_unauthorized
+ end
+end
diff --git a/modules/auth_saml/spec/contracts/saml/providers/delete_contract_spec.rb b/modules/auth_saml/spec/contracts/saml/providers/delete_contract_spec.rb
new file mode 100644
index 000000000000..2b0d80475ff8
--- /dev/null
+++ b/modules/auth_saml/spec/contracts/saml/providers/delete_contract_spec.rb
@@ -0,0 +1,51 @@
+# 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 "contracts/shared/model_contract_shared_context"
+
+RSpec.describe Saml::Providers::DeleteContract do
+ include_context "ModelContract shared context"
+
+ let(:provider) { build_stubbed(:saml_provider) }
+ let(:contract) { described_class.new provider, current_user }
+
+ context "when admin" do
+ let(:current_user) { build_stubbed(:admin) }
+
+ it_behaves_like "contract is valid"
+ end
+
+ context "when non-admin" do
+ let(:current_user) { build_stubbed(:user) }
+
+ it_behaves_like "contract is invalid", base: :error_unauthorized
+ end
+end
diff --git a/modules/auth_saml/spec/contracts/saml/providers/update_contract_spec.rb b/modules/auth_saml/spec/contracts/saml/providers/update_contract_spec.rb
new file mode 100644
index 000000000000..9954641223d7
--- /dev/null
+++ b/modules/auth_saml/spec/contracts/saml/providers/update_contract_spec.rb
@@ -0,0 +1,49 @@
+#-- 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 "contracts/shared/model_contract_shared_context"
+
+RSpec.describe Saml::Providers::UpdateContract do
+ let(:provider) { build_stubbed(:saml_provider) }
+ let(:contract) { described_class.new provider, current_user }
+
+ include_context "ModelContract shared context"
+
+ context "when admin" do
+ let(:current_user) { build_stubbed(:admin) }
+
+ it_behaves_like "contract is valid"
+ end
+
+ context "when non-admin" do
+ let(:current_user) { build_stubbed(:user) }
+
+ it_behaves_like "contract is invalid", base: :error_unauthorized
+ end
+end
diff --git a/modules/auth_saml/spec/factories/saml_provider_factory.rb b/modules/auth_saml/spec/factories/saml_provider_factory.rb
new file mode 100644
index 000000000000..a53c2c6857c6
--- /dev/null
+++ b/modules/auth_saml/spec/factories/saml_provider_factory.rb
@@ -0,0 +1,72 @@
+#-- 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_relative "../support/certificate_helper"
+
+FactoryBot.define do
+ factory(:saml_provider, class: "Saml::Provider") do
+ sequence(:display_name) { |n| "Saml Provider #{n}" }
+ sequence(:slug) { |n| "saml-#{n}" }
+ creator factory: :user
+ available { true }
+
+ idp_cert { CertificateHelper.valid_certificate.to_pem }
+ idp_cert_fingerprint { nil }
+
+ sp_entity_id { "http://#{Setting.host_name}" }
+
+ idp_sso_service_url { "https://example.com/sso" }
+ idp_slo_service_url { "https://example.com/slo" }
+
+ mapping_login { Saml::Defaults::MAIL_MAPPING }
+ mapping_mail { Saml::Defaults::MAIL_MAPPING }
+ mapping_firstname { Saml::Defaults::FIRSTNAME_MAPPING }
+ mapping_lastname { Saml::Defaults::LASTNAME_MAPPING }
+
+ trait :with_requested_attributes do
+ requested_mail_attribute { Saml::Defaults::MAIL_MAPPING.split("\n").first.strip }
+ requested_login_attribute { Saml::Defaults::MAIL_MAPPING.split("\n").first.strip }
+ requested_firstname_attribute { Saml::Defaults::FIRSTNAME_MAPPING.split("\n").first.strip }
+ requested_lastname_attribute { Saml::Defaults::LASTNAME_MAPPING.split("\n").first.strip }
+ requested_login_format { Saml::Defaults::ATTRIBUTE_FORMATS.first }
+ requested_mail_format { Saml::Defaults::ATTRIBUTE_FORMATS.first }
+ requested_firstname_format { Saml::Defaults::ATTRIBUTE_FORMATS.first }
+ requested_lastname_format { Saml::Defaults::ATTRIBUTE_FORMATS.first }
+ end
+
+ trait :with_encryption do
+ certificate { CertificateHelper.valid_certificate.to_pem }
+ private_key { CertificateHelper.private_key.to_pem }
+ authn_requests_signed { true }
+ want_assertions_signed { true }
+ want_assertions_encrypted { true }
+ digest_method { Saml::Defaults::DIGEST_METHODS["SHA-256"] }
+ signature_method { Saml::Defaults::SIGNATURE_METHODS["RSA SHA-256"] }
+ end
+ 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
new file mode 100644
index 000000000000..2df6ed5f2512
--- /dev/null
+++ b/modules/auth_saml/spec/features/administration/saml_crud_spec.rb
@@ -0,0 +1,173 @@
+#-- 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 administration CRUD",
+ :js,
+ :with_cuprite do
+ shared_let(:user) { create(:admin) }
+
+ before do
+ login_as(user)
+ end
+
+ context "with EE", with_ee: %i[sso_auth_providers] do
+ it "can manage SAML providers through the UI" do
+ visit "/admin/saml/providers"
+ expect(page).to have_text "No SAML providers configured yet."
+ click_link_or_button "SAML identity provider"
+
+ fill_in "Name", with: "My provider"
+ click_link_or_button "Continue"
+
+ expect(page).to have_css("h1", text: "My provider")
+
+ # Skip metadata
+ click_link_or_button "Continue"
+
+ # Fill out configuration
+ fill_in "Identity provider login endpoint", with: "https://example.com/sso"
+ fill_in "Identity provider logout endpoint", with: "https://example.com/slo"
+ fill_in "Public certificate of identity provider", with: CertificateHelper.valid_certificate.to_pem
+
+ click_link_or_button "Continue"
+
+ # Encryption form
+ check "Sign SAML AuthnRequests"
+ fill_in "Certificate used by OpenProject for SAML requests", with: CertificateHelper.valid_certificate.to_pem
+ fill_in "Corresponding private key for OpenProject SAML requests", with: CertificateHelper.private_key.private_to_pem
+
+ click_link_or_button "Continue"
+
+ # Mapping form
+ fill_in "Mapping for: Username", with: "login\nmail", fill_options: { clear: :backspace }
+ fill_in "Mapping for: Email", with: "mail", fill_options: { clear: :backspace }
+ fill_in "Mapping for: First name", with: "myName", fill_options: { clear: :backspace }
+ fill_in "Mapping for: Last name", with: "myLastName", fill_options: { clear: :backspace }
+ fill_in "Mapping for: Internal user id", with: "uid", fill_options: { clear: :backspace }
+
+ click_link_or_button "Continue"
+
+ # Skip requested attributes form
+ click_link_or_button "Finish setup"
+
+ # We're now on the show page
+ within_test_selector("saml_provider_metadata") do
+ expect(page).to have_text "Not configured"
+ end
+
+ # Back to index
+ visit "/admin/saml/providers"
+ expect(page).to have_text "My provider"
+ expect(page).to have_css(".users", text: 0)
+ expect(page).to have_css(".creator", text: user.name)
+
+ click_link_or_button "My provider"
+
+ provider = Saml::Provider.find_by!(display_name: "My provider")
+ expect(provider.slug).to eq "saml-my-provider"
+ expect(provider.idp_cert.strip.gsub("\r\n", "\n")).to eq CertificateHelper.valid_certificate.to_pem.strip
+ expect(provider.certificate.strip.gsub("\r\n", "\n")).to eq CertificateHelper.valid_certificate.to_pem.strip
+ expect(provider.private_key.strip.gsub("\r\n", "\n")).to eq CertificateHelper.private_key.private_to_pem.strip
+ expect(provider.idp_sso_service_url).to eq "https://example.com/sso"
+ expect(provider.idp_slo_service_url).to eq "https://example.com/slo"
+ expect(provider.mapping_login).to eq "login\nmail"
+ expect(provider.mapping_mail).to eq "mail"
+ expect(provider.mapping_firstname).to eq "myName"
+ expect(provider.mapping_lastname).to eq "myLastName"
+ expect(provider.mapping_uid).to eq "uid"
+ expect(provider.authn_requests_signed).to be true
+
+ accept_confirm do
+ click_link_or_button "Delete"
+ end
+
+ expect(page).to have_text "No SAML providers configured yet."
+ end
+
+ it "can import metadata from XML" do
+ visit "/admin/saml/providers/new"
+ fill_in "Name", with: "My provider"
+ click_link_or_button "Continue"
+
+ choose "Metadata XML"
+
+ metadata = Rails.root.join("modules/auth_saml/spec/fixtures/idp_metadata.xml").read
+ fill_in "saml_provider_metadata_xml", with: metadata
+
+ click_link_or_button "Continue"
+ expect(page).to have_text "This information has been pre-filled using the supplied metadata."
+ expect(page).to have_field "Service entity ID", with: "http://#{Setting.host_name}/"
+ expect(page).to have_field "Identity provider login endpoint", with: "https://example.com/login"
+ expect(page).to have_field "Identity provider logout endpoint", with: "https://example.com/logout"
+ end
+
+ it "can import metadata from URL", :webmock do
+ visit "/admin/saml/providers/new"
+ fill_in "Name", with: "My provider"
+ click_link_or_button "Continue"
+
+ url = "https://example.com/metadata"
+ metadata = Rails.root.join("modules/auth_saml/spec/fixtures/idp_metadata.xml").read
+ stub_request(:get, url).to_return(status: 200, body: metadata)
+
+ choose "Metadata URL"
+
+ fill_in "saml_provider_metadata_url", with: url
+
+ click_link_or_button "Continue"
+ expect(page).to have_text "This information has been pre-filled using the supplied metadata."
+ expect(page).to have_field "Service entity ID", with: "http://#{Setting.host_name}/"
+ expect(page).to have_field "Identity provider login endpoint", with: "https://example.com/login"
+ expect(page).to have_field "Identity provider logout endpoint", with: "https://example.com/logout"
+
+ expect(WebMock).to have_requested(:get, url)
+ end
+
+ context "when provider exists already" do
+ let!(:provider) { create(:saml_provider, display_name: "My provider") }
+
+ it "shows an error trying to use the same name" do
+ visit "/admin/saml/providers/new"
+ fill_in "Name", with: "My provider"
+ click_link_or_button "Continue"
+
+ expect(page).to have_text "Display name has already been taken."
+ end
+ end
+ end
+
+ context "without EE", without_ee: %i[sso_auth_providers] do
+ it "renders the upsale page" do
+ visit "/admin/saml/providers"
+ expect(page).to have_text "SAML identity providers is an Enterprise add-on"
+ end
+ end
+end
diff --git a/modules/auth_saml/spec/fixtures/idp_cert_plain.txt b/modules/auth_saml/spec/fixtures/idp_cert_plain.txt
new file mode 100644
index 000000000000..a220aa269674
--- /dev/null
+++ b/modules/auth_saml/spec/fixtures/idp_cert_plain.txt
@@ -0,0 +1 @@
+MIICpzCCAhACCQDuFX0Db5iljDANBgkqhkiG9w0BAQsFADCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wHhcNMTgwNTE1MTgxMTEwWhcNMjgwNTEyMTgxMTEwWjCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAJEBNDJKH5nXr0hZKcSNIY1l4HeYLPBEKJLXyAnoFTdgGrvi40YyIx9lHh0LbDVWCgxJp21BmKll0CkgmeKidvGlr3FUwtETro44L+SgmjiJNbftvFxhNkgA26O2GDQuBoQwgSiagVadWXwJKkodH8tx4ojBPYK1pBO8fHf3wOnxAgMBAAEwDQYJKoZIhvcNAQELBQADgYEACIylhvh6T758hcZjAQJiV7rMRg+Omb68iJI4L9f0cyBcJENR+1LQNgUGyFDMm9Wm9o81CuIKBnfpEE2Jfcs76YVWRJy5xJ11GFKJJ5T0NEB7txbUQPoJOeNoE736lF5vYw6YKp8fJqPW0L2PLWe9qTn8hxpdnjo3k6r5gXyl8tk=
diff --git a/modules/auth_saml/spec/fixtures/idp_metadata.xml b/modules/auth_saml/spec/fixtures/idp_metadata.xml
new file mode 100644
index 000000000000..9ccda10771d7
--- /dev/null
+++ b/modules/auth_saml/spec/fixtures/idp_metadata.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+ MIIDHTCCAgWgAwIBAgIJaPVOFuER4IyxMA0GCSqGSIb3DQEBCwUAMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTAeFw0yMzA0MjUwODU0MThaFw0zNzAxMDEwODU0MThaMCwxKjAoBgNVBAMTIWRldi10eXQzaDZkNTZjeTB4aXA3LnVzLmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK/2idoD9sJDzYukmOCVC2Qp4nLm2WGkIbdAudSDzb3hEDVTebAUXeDUqi9nQMtXjS5lvXYVQMIHdq1yRLGciSBviO+qT/FGNRm/VncpWYt0mVvf6t/9Y1PJ1krFlo/72FN3VBEClqAccVryPq+pjwswZMWbgBi3BsMdi+HYW2mgPRKeAemaEYcPwPyUpCBiVQo/6cCSlBB+Kigyp33LAJZVakwNA5pxEfnhUwifiyBzO+xH5m4ol8mcDznsmiBYddxqVQ7qaYds4HSpwSzArdxLnlFj1nVwusJyGsjZD4pCMZuoWXLGPs4ldBgzLH5Dq6UtsCKPZGhRzYA4Au7qVGcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUrh9OF0EmZv3h3BmcuCF05qLDYokwDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQAoo/z1LTjEmGrm9ilObVDzPSWlvOqDq1lkP8nrROpYkghDPxXQl+8nJ4J0gKwDP6BgTjWzhMk8MyoeCn8wZGI7oBOeBPOeJCbc6WYW2HGvtc86nO3JHHdvDYMO3HbR9LFoxBXhF95nWwgmPd+9uxdHrdfQ1CIXasATWToQgud6rkCgMcNP25LFds36iMCzXDxKiYvn54JaXmGo567U1Rx6O4AwnCG1RHTdtx/CdiIuPAOBl/xUnnsVeCPQgqNl53/6Qtmk9kg15GeeFDsjJoHoZuLZANaGMUBLRMzLU4al3/SRgPJLaEq46BaRq/oX0WvWlGiVegHNWjpOJ5ZSEeS5
+
+
+
+
+
+ urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
+ urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
+ urn:oasis:names:tc:SAML:2.0:nameid-format:transient
+
+
+
+
+
+
+
+
+
diff --git a/modules/auth_saml/spec/fixtures/saml_response.xml b/modules/auth_saml/spec/fixtures/saml_response.xml
index b995b7f130f7..691b6b8a1c6c 100644
--- a/modules/auth_saml/spec/fixtures/saml_response.xml
+++ b/modules/auth_saml/spec/fixtures/saml_response.xml
@@ -1 +1 @@
-https://foobar.org/B4p7Ab3QK9Y3XQ5LoUMcJ3hxWkBoFTLMVKmBNS01e0=f4WLA8kcPXYTIn/8Ra1PjByizB8fqM22H+AJXGfPoO2ZqXEkQzWNcS66FluYns/3XOSP/8yTk5fK7AhOAssXCif6O94XCdk7+roj/Xl+AG9BrgHDQU9ytblNmTU0Q0EFEarlgAuPocCimBqjLcvRFLzyr/nia6XGoREx77bRjSw=MIICpzCCAhACCQDuFX0Db5iljDANBgkqhkiG9w0BAQsFADCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wHhcNMTgwNTE1MTgxMTEwWhcNMjgwNTEyMTgxMTEwWjCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAJEBNDJKH5nXr0hZKcSNIY1l4HeYLPBEKJLXyAnoFTdgGrvi40YyIx9lHh0LbDVWCgxJp21BmKll0CkgmeKidvGlr3FUwtETro44L+SgmjiJNbftvFxhNkgA26O2GDQuBoQwgSiagVadWXwJKkodH8tx4ojBPYK1pBO8fHf3wOnxAgMBAAEwDQYJKoZIhvcNAQELBQADgYEACIylhvh6T758hcZjAQJiV7rMRg+Omb68iJI4L9f0cyBcJENR+1LQNgUGyFDMm9Wm9o81CuIKBnfpEE2Jfcs76YVWRJy5xJ11GFKJJ5T0NEB7txbUQPoJOeNoE736lF5vYw6YKp8fJqPW0L2PLWe9qTn8hxpdnjo3k6r5gXyl8tk=https://foobar.orgZtp3jfFiai++82S52hLRZZMUEAm0gYpkSonPC0aVV7I=bQuXxqLvkyFZuEpD8jab4xBGOz0Xg2i1DhZheCR2CN12VM8PcLhvWjZF7APvNsI6D7HC1SmBQIg2dAQUB1TGO5+ZD5TDDQd90qiKvesW1uYWWh5QP7rvphKvJl/cBXH8w3hbMnC/noC9DMQVL1ugjpa5y7Gzsj6JwNYhjWDPjHc=MIICpzCCAhACCQDuFX0Db5iljDANBgkqhkiG9w0BAQsFADCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wHhcNMTgwNTE1MTgxMTEwWhcNMjgwNTEyMTgxMTEwWjCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAJEBNDJKH5nXr0hZKcSNIY1l4HeYLPBEKJLXyAnoFTdgGrvi40YyIx9lHh0LbDVWCgxJp21BmKll0CkgmeKidvGlr3FUwtETro44L+SgmjiJNbftvFxhNkgA26O2GDQuBoQwgSiagVadWXwJKkodH8tx4ojBPYK1pBO8fHf3wOnxAgMBAAEwDQYJKoZIhvcNAQELBQADgYEACIylhvh6T758hcZjAQJiV7rMRg+Omb68iJI4L9f0cyBcJENR+1LQNgUGyFDMm9Wm9o81CuIKBnfpEE2Jfcs76YVWRJy5xJ11GFKJJ5T0NEB7txbUQPoJOeNoE736lF5vYw6YKp8fJqPW0L2PLWe9qTn8hxpdnjo3k6r5gXyl8tk=testuserurn:oasis:names:tc:SAML:2.0:ac:classes:unspecifiedfoo@example.comtestuser
+https://foobar.org1vN2lq+9ndeRFBMdkGaZxQdubvqu2NbLN0tW15WVDAk=cJp2jnvy7FanTRJGh7cvZhywfspxyJSvNpK5HJntIkaxgCoviApZRk0zdTuiJUiV/dSfp9MvGh0tAQ2cUWwlnMuBXASR6RIsd9itBQAoyCQwHyi7/cDKgreF2M/so6G9Phyglek154759mh/7EvTu+P5+KAgof+YB41zQdsi8EY=MIICpzCCAhACCQDuFX0Db5iljDANBgkqhkiG9w0BAQsFADCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wHhcNMTgwNTE1MTgxMTEwWhcNMjgwNTEyMTgxMTEwWjCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAJEBNDJKH5nXr0hZKcSNIY1l4HeYLPBEKJLXyAnoFTdgGrvi40YyIx9lHh0LbDVWCgxJp21BmKll0CkgmeKidvGlr3FUwtETro44L+SgmjiJNbftvFxhNkgA26O2GDQuBoQwgSiagVadWXwJKkodH8tx4ojBPYK1pBO8fHf3wOnxAgMBAAEwDQYJKoZIhvcNAQELBQADgYEACIylhvh6T758hcZjAQJiV7rMRg+Omb68iJI4L9f0cyBcJENR+1LQNgUGyFDMm9Wm9o81CuIKBnfpEE2Jfcs76YVWRJy5xJ11GFKJJ5T0NEB7txbUQPoJOeNoE736lF5vYw6YKp8fJqPW0L2PLWe9qTn8hxpdnjo3k6r5gXyl8tk=https://foobar.orgkavIddInjlzr6lJ6iGeIUU2DerATKPNhZcnezG60seE=Y2SLLZ2+M4wX0dTd+b/MS+wt9wrPiyi32kL/qTSNIIW9xBHRYkp21xqJ+kwFyk3EjPbcpUUvrAxztlJ6GHsc/rUrWfykHZp/NSFDKtaSeRL44m8jH+AA5lDFIWWl8Zw/OIWKJLfE4IfQTfvjDZz12SiJaj4wgby2enXkvLlUxC8=MIICpzCCAhACCQDuFX0Db5iljDANBgkqhkiG9w0BAQsFADCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wHhcNMTgwNTE1MTgxMTEwWhcNMjgwNTEyMTgxMTEwWjCBlzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHU2FtbGluZzEPMA0GA1UECwwGU2FsaW5nMRQwEgYDVQQDDAtjYXByaXphLmNvbTEmMCQGCSqGSIb3DQEJARYXZW5naW5lZXJpbmdAY2Fwcml6YS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAJEBNDJKH5nXr0hZKcSNIY1l4HeYLPBEKJLXyAnoFTdgGrvi40YyIx9lHh0LbDVWCgxJp21BmKll0CkgmeKidvGlr3FUwtETro44L+SgmjiJNbftvFxhNkgA26O2GDQuBoQwgSiagVadWXwJKkodH8tx4ojBPYK1pBO8fHf3wOnxAgMBAAEwDQYJKoZIhvcNAQELBQADgYEACIylhvh6T758hcZjAQJiV7rMRg+Omb68iJI4L9f0cyBcJENR+1LQNgUGyFDMm9Wm9o81CuIKBnfpEE2Jfcs76YVWRJy5xJ11GFKJJ5T0NEB7txbUQPoJOeNoE736lF5vYw6YKp8fJqPW0L2PLWe9qTn8hxpdnjo3k6r5gXyl8tk=foourn:oasis:names:tc:SAML:2.0:ac:classes:unspecifiedfoo@example.comFooUserfoo_user
diff --git a/modules/auth_saml/spec/lib/open_project/auth_saml_spec.rb b/modules/auth_saml/spec/lib/open_project/auth_saml_spec.rb
index 4aff710610ec..263cd698e03d 100644
--- a/modules/auth_saml/spec/lib/open_project/auth_saml_spec.rb
+++ b/modules/auth_saml/spec/lib/open_project/auth_saml_spec.rb
@@ -1,66 +1,34 @@
-require File.dirname(__FILE__) + "/../../spec_helper"
+require "#{File.dirname(__FILE__)}/../../spec_helper"
require "open_project/auth_saml"
RSpec.describe OpenProject::AuthSaml do
- before do
- OpenProject::AuthSaml.reload_configuration!
- end
-
- after do
- OpenProject::AuthSaml.reload_configuration!
- end
-
describe ".configuration" do
- let(:config) do
- # the `configuration` method is cached to avoid
- # loading the SAML file more than once
- # thus remove any cached value here
- OpenProject::AuthSaml.remove_instance_variable(:@saml_settings)
- OpenProject::AuthSaml.configuration
- end
+ let!(:provider) { create(:saml_provider, display_name: "My SSO", slug: "my-saml") }
+
+ subject { described_class.configuration[:"my-saml"] }
- context(
- "with configuration",
- with_config: {
- saml: {
- my_saml: {
- name: "saml",
- display_name: "My SSO"
- }
- }
- }
- ) do
- it "contains the configuration from OpenProject::Configuration (or settings.yml) by default" do
- expect(config[:my_saml][:name]).to eq "saml"
- expect(config[:my_saml][:display_name]).to eq "My SSO"
- end
+ it "contains the configuration from OpenProject::Configuration (or settings.yml) by default",
+ :aggregate_failures do
+ expect(subject[:name]).to eq "my-saml"
+ expect(subject[:display_name]).to eq "My SSO"
+ expect(subject[:idp_cert].strip).to eq provider.idp_cert.strip
+ expect(subject[:assertion_consumer_service_url]).to eq "http://#{Setting.host_name}/auth/my-saml/callback"
+ expect(subject[:idp_sso_service_url]).to eq "https://example.com/sso"
+ expect(subject[:idp_slo_service_url]).to eq "https://example.com/slo"
- context(
- "with settings override from database",
- with_settings: {
- plugin_openproject_auth_saml: {
- providers: {
- my_saml: {
- display_name: "Your SSO"
- },
- new_saml: {
- name: "new_saml",
- display_name: "Another SAML"
- }
- }
- }
- }
- ) do
- it "overrides the existing configuration where defined" do
- expect(config[:my_saml][:name]).to eq "saml"
- expect(config[:my_saml][:display_name]).to eq "Your SSO"
- end
+ attributes = subject[:attribute_statements]
+ expect(attributes[:email]).to eq Saml::Defaults::MAIL_MAPPING.split("\n")
+ expect(attributes[:login]).to eq Saml::Defaults::MAIL_MAPPING.split("\n")
+ expect(attributes[:first_name]).to eq Saml::Defaults::FIRSTNAME_MAPPING.split("\n")
+ expect(attributes[:last_name]).to eq Saml::Defaults::LASTNAME_MAPPING.split("\n")
- it "defines new providers if given" do
- expect(config[:new_saml][:name]).to eq "new_saml"
- expect(config[:new_saml][:display_name]).to eq "Another SAML"
- end
- end
+ security = subject[:security]
+ expect(security[:check_idp_cert_expiration]).to be false
+ expect(security[:check_sp_cert_expiration]).to be false
+ expect(security[:metadata_signed]).to be false
+ expect(security[:authn_requests_signed]).to be false
+ expect(security[:want_assertions_signed]).to be false
+ expect(security[:want_assertions_encrypted]).to be false
end
end
end
diff --git a/modules/auth_saml/spec/models/saml/provider_spec.rb b/modules/auth_saml/spec/models/saml/provider_spec.rb
new file mode 100644
index 000000000000..3c88b8624d84
--- /dev/null
+++ b/modules/auth_saml/spec/models/saml/provider_spec.rb
@@ -0,0 +1,284 @@
+#-- 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::Provider do
+ let(:instance) { described_class.new(display_name: "saml", slug: "saml") }
+
+ describe "#seeded_from_env?" do
+ subject { instance.seeded_from_env? }
+
+ context "when the provider is not seeded from the environment" do
+ it { is_expected.to be false }
+ end
+
+ context "when the provider is seeded from the environment",
+ with_settings: { seed_saml_provider: { saml: {} } } do
+ it { is_expected.to be true }
+ end
+ end
+
+ describe "#has_metadata?" do
+ subject { instance.has_metadata? }
+
+ context "when metadata_xml is set" do
+ before { instance.metadata_xml = "metadata" }
+
+ it { is_expected.to be true }
+ end
+
+ context "when metadata_url is set" do
+ before { instance.metadata_url = "metadata" }
+
+ it { is_expected.to be true }
+ end
+
+ context "when metadata_xml and metadata_url are not set" do
+ it { is_expected.to be false }
+ end
+ end
+
+ describe "#metadata_changed?" do
+ subject { instance.metadata_updated? }
+
+ context "when metadata_xml is changed" do
+ before { instance.metadata_xml = "metadata" }
+
+ it { is_expected.to be true }
+ end
+
+ context "when metadata_url is changed" do
+ before { instance.metadata_url = "metadata" }
+
+ it { is_expected.to be true }
+ end
+
+ context "when metadata_xml and metadata_url are not changed" do
+ it { is_expected.to be false }
+ end
+ end
+
+ describe "#metadata_endpoint", with_settings: { host_name: "example.com" } do
+ subject { instance.metadata_endpoint }
+
+ it { is_expected.to eq "http://example.com/auth/saml/metadata" }
+ end
+
+ describe "#configured?" do
+ subject { instance.configured? }
+
+ context "when fully present" do
+ let(:instance) { build_stubbed(:saml_provider) }
+
+ it { is_expected.to be true }
+ end
+
+ context "when details missing" do
+ it { is_expected.to be false }
+ end
+ end
+
+ describe "#mapping_configured?" do
+ subject { instance.mapping_configured? }
+
+ context "when fully present" do
+ let(:instance) { build_stubbed(:saml_provider) }
+
+ it { is_expected.to be true }
+ end
+
+ context "when parts missing" do
+ before do
+ instance.mapping_mail = "foo"
+ end
+
+ it { is_expected.to be false }
+ end
+
+ context "when optional uid missing" do
+ before do
+ instance.mapping_mail = "foo"
+ instance.mapping_login = "foo"
+ instance.mapping_firstname = "foo"
+ instance.mapping_lastname = "foo"
+ end
+
+ it { is_expected.to be true }
+ end
+ end
+
+ describe "#loaded_certificate" do
+ subject { instance.loaded_certificate }
+
+ before do
+ instance.certificate = certificate
+ end
+
+ context "when blank" do
+ let(:certificate) { nil }
+
+ it { is_expected.to be_nil }
+ end
+
+ context "when present" do
+ let(:certificate) { CertificateHelper.valid_certificate.to_pem }
+
+ it { is_expected.to be_a(OpenSSL::X509::Certificate) }
+ end
+
+ context "when invalid" do
+ let(:certificate) { "invalid" }
+
+ it "raises an error" do
+ expect { subject }.to raise_error(OpenSSL::X509::CertificateError)
+ end
+ end
+ end
+
+ describe "#loaded_private_key" do
+ subject { instance.loaded_private_key }
+
+ before do
+ instance.private_key = private_key
+ end
+
+ context "when blank" do
+ let(:private_key) { nil }
+
+ it { is_expected.to be_nil }
+ end
+
+ context "when present" do
+ let(:private_key) { CertificateHelper.private_key.private_to_pem }
+
+ it { is_expected.to be_a(OpenSSL::PKey::RSA) }
+ end
+
+ context "when invalid" do
+ let(:private_key) { "invalid" }
+
+ it "raises an error" do
+ expect { subject }.to raise_error(OpenSSL::PKey::RSAError)
+ end
+ end
+ end
+
+ describe "#loaded_idp_certificates" do
+ subject { instance.loaded_idp_certificates }
+
+ before do
+ instance.idp_cert = certificate
+ end
+
+ context "when blank" do
+ let(:certificate) { nil }
+
+ it { is_expected.to be_nil }
+ end
+
+ context "when single" do
+ let(:certificate) { CertificateHelper.valid_certificate.to_pem }
+
+ it "is an array of one certificate", :aggregate_failures do
+ expect(subject).to be_a(Array)
+ expect(subject.count).to eq(1)
+ expect(subject).to all(be_a(OpenSSL::X509::Certificate))
+ end
+ end
+
+ context "when multi" do
+ let(:input) { CertificateHelper.valid_certificate.to_pem }
+ let(:certificate) { "#{input}\n#{input}" }
+
+ it "is an array of two certificates", :aggregate_failures do
+ expect(subject).to be_a(Array)
+ expect(subject.count).to eq(2)
+ expect(subject).to all(be_a(OpenSSL::X509::Certificate))
+ end
+ end
+
+ context "when invalid" do
+ let(:certificate) { "invalid" }
+
+ it "raises an error" do
+ expect { subject }.to raise_error(OpenSSL::X509::CertificateError)
+ end
+ end
+ end
+
+ describe "#idp_certificate_valid?" do
+ subject { instance.idp_certificate_valid? }
+
+ before do
+ instance.idp_cert = certificate
+ end
+
+ context "when blank" do
+ let(:certificate) { nil }
+
+ it { is_expected.to be false }
+ end
+
+ context "when single valid" do
+ let(:certificate) { CertificateHelper.valid_certificate.to_pem }
+
+ it { is_expected.to be true }
+ end
+
+ context "when single expired" do
+ let(:certificate) { CertificateHelper.expired_certificate.to_pem }
+
+ it { is_expected.to be false }
+ end
+
+ context "when first valid, second expired" do
+ let(:valid) { CertificateHelper.valid_certificate.to_pem }
+ let(:invalid) { CertificateHelper.expired_certificate.to_pem }
+ let(:certificate) { "#{valid}\n#{invalid}" }
+
+ it { is_expected.to be true }
+ end
+
+ context "when first expired, second valid" do
+ let(:valid) { CertificateHelper.valid_certificate.to_pem }
+ let(:invalid) { CertificateHelper.expired_certificate.to_pem }
+ let(:certificate) { "#{invalid}\n#{valid}" }
+
+ it { is_expected.to be true }
+ end
+
+ context "when both expired" do
+ let(:invalid) { CertificateHelper.expired_certificate.to_pem }
+ let(:certificate) { "#{invalid}\n#{invalid}" }
+
+ it { is_expected.to be false }
+ end
+ end
+end
diff --git a/modules/auth_saml/spec/requests/saml_metadata_endpoint_spec.rb b/modules/auth_saml/spec/requests/saml_metadata_endpoint_spec.rb
new file mode 100644
index 000000000000..eeab93f846ea
--- /dev/null
+++ b/modules/auth_saml/spec/requests/saml_metadata_endpoint_spec.rb
@@ -0,0 +1,116 @@
+#-- 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 "rack/test"
+
+RSpec.describe "SAML metadata endpoint", with_ee: %i[sso_auth_providers] do
+ include Rack::Test::Methods
+ include API::V3::Utilities::PathHelper
+
+ subject do
+ temp = Nokogiri::XML(last_response.body)
+
+ # The ds prefix is not defined on root,
+ # which Nokogiri complains about
+ temp.root["xmlns:ds"] = "http://www.w3.org/2000/09/xmldsig#"
+
+ Nokogiri::XML(temp.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML))
+ end
+
+ before do
+ provider
+ get "/auth/saml/metadata"
+ end
+
+ context "with basic provider" do
+ let(:provider) do
+ create(:saml_provider, slug: "saml")
+ end
+
+ it "returns the metadata" do
+ expect(last_response).to be_successful
+ expect(subject.at_xpath("//md:EntityDescriptor")["entityID"]).to eq "http://test.host"
+
+ sso = subject.at_xpath("//md:SPSSODescriptor")
+ expect(sso["AuthnRequestsSigned"]).to eq "false"
+ expect(sso["WantAssertionsSigned"]).to eq "false"
+
+ consumer = sso.at_xpath("//md:AssertionConsumerService")
+ expect(consumer["Binding"]).to eq "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+ expect(consumer["Location"]).to eq "http://test.host/auth/saml/callback"
+ end
+ end
+
+ context "with elaborate provider" do
+ let(:provider) do
+ create(:saml_provider,
+ :with_encryption,
+ :with_requested_attributes,
+ slug: "saml")
+ end
+
+ it "returns the metadata", :aggregate_failures do # rubocop:disable RSpec/ExampleLength
+ expect(last_response).to be_successful
+ expect(subject.at_xpath("//md:EntityDescriptor")["entityID"]).to eq "http://test.host"
+
+ sso = subject.at_xpath("//md:SPSSODescriptor")
+ expect(sso["AuthnRequestsSigned"]).to eq "true"
+ expect(sso["WantAssertionsSigned"]).to eq "true"
+
+ # Expect signature present
+ signature = subject.at_xpath("//ds:Signature")
+ expect(signature.at_xpath("//ds:SignatureMethod")["Algorithm"]).to eq "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
+ expect(signature.at_xpath("//ds:DigestMethod")["Algorithm"]).to eq "http://www.w3.org/2001/04/xmlenc#sha256"
+
+ expect(signature.at_xpath("//ds:DigestValue")).to be_present
+
+ signing = signature.at_xpath("//md:KeyDescriptor[@use='signing']/ds:KeyInfo/ds:X509Data/ds:X509Certificate").text
+ expect(signing).to eq CertificateHelper.non_padded_string(:valid_certificate)
+
+ encryption = signature.at_xpath("//md:KeyDescriptor[@use='encryption']/ds:KeyInfo/ds:X509Data/ds:X509Certificate").text
+ expect(encryption).to eq CertificateHelper.non_padded_string(:valid_certificate)
+
+ consumer = sso.at_xpath("//md:AssertionConsumerService")
+ expect(consumer["Binding"]).to eq "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+ expect(consumer["Location"]).to eq "http://test.host/auth/saml/callback"
+
+ consuming = consumer.at_xpath("//md:AttributeConsumingService")
+ requested = consuming.xpath("md:RequestedAttribute")
+ attributes = requested.map { |x| [x["FriendlyName"], x["Name"]] }
+ expect(attributes).to contain_exactly ["Login", "mail"],
+ ["First Name", "givenName"],
+ ["Last Name", "sn"],
+ ["Email", "mail"]
+
+ requested.each do |attr|
+ expect(attr["NameFormat"]).to eq "urn:oasis:names:tc:SAML:2.0:attrname-format:basic"
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb b/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb
index a78a8a3a05c6..4069e63b8226 100644
--- a/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb
+++ b/modules/auth_saml/spec/requests/saml_provider_callback_spec.rb
@@ -31,10 +31,23 @@
RSpec.describe "SAML provider callback",
type: :rails_request,
- with_ee: %i[openid_providers] do
+ with_ee: %i[sso_auth_providers] do
include Rack::Test::Methods
include API::V3::Utilities::PathHelper
+ let!(:provider) do
+ create(:saml_provider,
+ display_name: "SAML",
+ slug: "saml",
+ digest_method: "http://www.w3.org/2001/04/xmlenc#sha256",
+ sp_entity_id: "https://foobar.org",
+ idp_cert:,
+ idp_cert_fingerprint:)
+ end
+
+ let(:idp_cert) { nil }
+ let(:idp_cert_fingerprint) { "B7:11:A4:22:A0:57:9D:A6:30:06:3C:BF:AC:44:8F:90:BE:5A:E2:3F" }
+
let(:saml_response) do
xml = File.read("#{File.dirname(__FILE__)}/../fixtures/saml_response.xml")
Base64.encode64(xml)
@@ -44,41 +57,12 @@
{ SAMLResponse: saml_response }
end
- let(:issuer) { "https://foobar.org" }
- let(:fingerprint) { "b711a422a0579da630063cbfac448f90be5ae23f" }
-
- let(:config) do
- {
- "name" => "saml",
- "display_name" => "SAML",
- "assertion_consumer_service_url" => "http://localhost:3000/auth/saml/callback",
- "issuer" => issuer,
- "idp_cert_fingerprint" => fingerprint,
- "idp_sso_target_url" => "https://foobar.org/login",
- "idp_slo_target_url" => "https://foobar.org/logout",
- "security" => {
- "digest_method" => "http://www.w3.org/2001/04/xmlenc#sha256",
- "check_idp_cert_expiration" => false
- },
- "attribute_statements" => {
- "email" => ["email", "urn:oid:0.9.2342.19200300.100.1.3"],
- "login" => ["uid", "email", "urn:oid:0.9.2342.19200300.100.1.3"],
- "first_name" => ["givenName", "urn:oid:2.5.4.42"],
- "last_name" => ["sn", "urn:oid:2.5.4.4"]
- }
- }
+ let(:request) do
+ post "/auth/saml/callback", body
end
- let(:request) { post "/auth/saml/callback", body }
-
subject do
- Timecop.freeze("2023-04-19T09:37:00Z".to_datetime) { request }
- end
-
- before do
- Setting.plugin_openproject_auth_saml = {
- "providers" => { "saml" => config }
- }
+ Timecop.freeze("2024-08-22T09:22:00Z".to_datetime) { request }
end
shared_examples "request fails" do |message|
@@ -89,9 +73,15 @@
end
end
- it "redirects user when no errors occured" do
- expect(subject.status).to eq(302)
- expect(subject.headers["Location"]).to eq("http://#{Setting.host_name}/two_factor_authentication/request")
+ shared_examples "request succeeds" do
+ it "redirects user when no errors occured" do
+ expect(subject.status).to eq(302)
+ expect(subject.headers["Location"]).to eq("http://#{Setting.host_name}/two_factor_authentication/request")
+ end
+ end
+
+ context "with valid basic configuration" do
+ it_behaves_like "request succeeds"
end
context "with an invalid timestamp" do
@@ -105,7 +95,21 @@
end
context "with an invalid fingerprint" do
- let(:fingerprint) { "invalid" }
+ let(:idp_cert_fingerprint) { "invalid" }
+
+ it_behaves_like "request fails"
+ end
+
+ context "when providing the valid certificate" do
+ let(:idp_cert) { File.read(Rails.root.join("modules/auth_saml/spec/fixtures/idp_cert_plain.txt").to_s) }
+ let(:idp_cert_fingerprint) { nil }
+
+ it_behaves_like "request succeeds"
+ end
+
+ context "when providing an invalid certificate" do
+ let(:idp_cert) { CertificateHelper.expired_certificate.to_pem }
+ let(:idp_cert_fingerprint) { nil }
it_behaves_like "request fails", "Fingerprint mismatch"
end
diff --git a/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb b/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb
new file mode 100644
index 000000000000..7fb41c26db90
--- /dev/null
+++ b/modules/auth_saml/spec/seeders/env_data/saml/provider_seeder_spec.rb
@@ -0,0 +1,147 @@
+# 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"
+
+RSpec.describe EnvData::Saml::ProviderSeeder, :settings_reset do
+ let(:seed_data) { Source::SeedData.new({}) }
+
+ subject(:seeder) { described_class.new(seed_data) }
+
+ before do
+ reset(:seed_saml_provider,
+ description: "Provide a SAML provider and sync its settings through ENV",
+ env_alias: "OPENPROJECT_SAML",
+ writable: false,
+ default: {},
+ format: :hash)
+ end
+
+ context "when not provided" do
+ it "does nothing" do
+ expect { seeder.seed! }.not_to change(Saml::Provider, :count)
+ end
+ end
+
+ context "when providing seed variables",
+ with_env: {
+ OPENPROJECT_SAML_SAML_NAME: "saml",
+ OPENPROJECT_SAML_SAML_DISPLAY__NAME: "Test SAML",
+ OPENPROJECT_SAML_SAML_ASSERTION__CONSUMER__SERVICE__URL: "some wrong value",
+ OPENPROJECT_SAML_SAML_ASSERTION__CONSUMER__SERVICE__BINDING: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
+ OPENPROJECT_SAML_SAML_SP__ENTITY__ID: "http://localhost:3000",
+ OPENPROJECT_SAML_SAML_IDP__CERT: CertificateHelper.valid_certificate.to_pem,
+ OPENPROJECT_SAML_SAML_IDP__SSO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK",
+ OPENPROJECT_SAML_SAML_IDP__SLO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK/logout",
+ OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_EMAIL: "['mail', 'urn:oid:2.5.4.42', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']",
+ OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_LOGIN: "['mail', 'urn:oid:2.5.4.42', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']",
+ OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_FIRST__NAME: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
+ OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_LAST__NAME: "['urn:oid:2.5.4.4', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname']"
+ } do
+ it "uses those variables" do
+ expect { seeder.seed! }.to change(Saml::Provider, :count).by(1)
+
+ provider = Saml::Provider.last
+ expect(provider.seeded_from_env?).to be true
+ expect(provider.slug).to eq "saml"
+ expect(provider.display_name).to eq "Test SAML"
+
+ expect(provider.sp_entity_id).to eq "http://localhost:3000"
+ expect(provider.assertion_consumer_service_url).to eq "http://localhost:3000/auth/saml/callback"
+ expect(provider.idp_cert).to eq OneLogin::RubySaml::Utils.format_cert(ENV.fetch("OPENPROJECT_SAML_SAML_IDP__CERT"))
+
+ expect(provider.mapping_login).to eq "mail\nurn:oid:2.5.4.42\nhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
+ expect(provider.mapping_mail).to eq "mail\nurn:oid:2.5.4.42\nhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
+ expect(provider.mapping_firstname).to eq "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"
+ expect(provider.mapping_lastname).to eq "urn:oid:2.5.4.4\nhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"
+ end
+
+ context "when provider already exists with that name" do
+ it "updates the provider" do
+ provider = Saml::Provider.create!(display_name: "Something", slug: "saml", mapping_mail: "old", creator: User.system)
+ expect { seeder.seed! }.not_to change(Saml::Provider, :count)
+
+ provider.reload
+
+ expect(provider.display_name).to eq "Test SAML"
+ expect(provider.seeded_from_env?).to be true
+ expect(provider.mapping_mail).to eq "mail\nurn:oid:2.5.4.42\nhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
+ end
+ end
+ end
+
+ context "when providing invalid variables",
+ with_env: {
+ OPENPROJECT_SAML_SAML_NAME: "saml",
+ OPENPROJECT_SAML_SAML_DISPLAY__NAME: "Test SAML",
+ OPENPROJECT_SAML_SAML_IDP__CERT: "invalid"
+ } do
+ it "raises an exception" do
+ expect { seeder.seed! }.to raise_error(/Idp cert is not a valid PEM-formatted certificate/)
+
+ expect(Saml::Provider.all).to be_empty
+ end
+ end
+
+ context "when providing multiple variables",
+ with_env: {
+ OPENPROJECT_SAML_SAML_NAME: "saml",
+ OPENPROJECT_SAML_SAML_DISPLAY__NAME: "Test SAML",
+ OPENPROJECT_SAML_SAML_ASSERTION__CONSUMER__SERVICE__URL: "some wrong value",
+ OPENPROJECT_SAML_SAML_ASSERTION__CONSUMER__SERVICE__BINDING: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
+ OPENPROJECT_SAML_SAML_SP__ENTITY__ID: "http://localhost:3000",
+ OPENPROJECT_SAML_SAML_IDP__CERT: CertificateHelper.non_padded_string(:valid_certificate),
+ OPENPROJECT_SAML_SAML_IDP__SSO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK",
+ OPENPROJECT_SAML_SAML_IDP__SLO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK/logout",
+ OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_EMAIL: "['mail', 'urn:oid:2.5.4.42', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']",
+ OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_LOGIN: "['mail', 'urn:oid:2.5.4.42', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']",
+ OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_FIRST__NAME: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
+ OPENPROJECT_SAML_SAML_ATTRIBUTE__STATEMENTS_LAST__NAME: "['urn:oid:2.5.4.4', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname']",
+ OPENPROJECT_SAML_MYSAML_NAME: "mysaml",
+ OPENPROJECT_SAML_MYSAML_DISPLAY__NAME: "Another SAML",
+ OPENPROJECT_SAML_MYSAML_ASSERTION__CONSUMER__SERVICE__URL: "some wrong value",
+ OPENPROJECT_SAML_MYSAML_ASSERTION__CONSUMER__SERVICE__BINDING: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
+ OPENPROJECT_SAML_MYSAML_SP__ENTITY__ID: "http://localhost:3000",
+ OPENPROJECT_SAML_MYSAML_IDP__CERT: CertificateHelper.non_padded_string(:valid_certificate),
+ OPENPROJECT_SAML_MYSAML_IDP__SSO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK",
+ OPENPROJECT_SAML_MYSAML_IDP__SLO__TARGET__URL: "https://saml.example.com/samlp/tPLrXaiBZhLNtzBMN8oNFQAzhFdGlUPK/logout",
+ OPENPROJECT_SAML_MYSAML_ATTRIBUTE__STATEMENTS_EMAIL: "['mail', 'urn:oid:2.5.4.42', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']",
+ OPENPROJECT_SAML_MYSAML_ATTRIBUTE__STATEMENTS_LOGIN: "['mail', 'urn:oid:2.5.4.42', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']",
+ OPENPROJECT_SAML_MYSAML_ATTRIBUTE__STATEMENTS_FIRST__NAME: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
+ OPENPROJECT_SAML_MYSAML_ATTRIBUTE__STATEMENTS_LAST__NAME: "['urn:oid:2.5.4.4', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname']"
+ } do
+ it "creates both" do
+ expect { seeder.seed! }.to change(Saml::Provider, :count).by(2)
+
+ providers = Saml::Provider.pluck(:slug)
+ expect(providers).to contain_exactly("saml", "mysaml")
+ end
+ end
+end
diff --git a/modules/auth_saml/spec/services/saml/configuration_mapper_spec.rb b/modules/auth_saml/spec/services/saml/configuration_mapper_spec.rb
new file mode 100644
index 000000000000..6cf93f4af3fb
--- /dev/null
+++ b/modules/auth_saml/spec/services/saml/configuration_mapper_spec.rb
@@ -0,0 +1,261 @@
+# 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"
+
+RSpec.describe Saml::ConfigurationMapper, type: :model do
+ let(:instance) { described_class.new(configuration) }
+ let(:result) { instance.call! }
+
+ describe "display_name" do
+ subject { result["display_name"] }
+
+ context "when provided" do
+ let(:configuration) { { display_name: "My SAML Provider" } }
+
+ it { is_expected.to eq("My SAML Provider") }
+ end
+
+ context "when not provided" do
+ let(:configuration) { {} }
+
+ it { is_expected.to eq("SAML") }
+ end
+ end
+
+ describe "slug" do
+ subject { result["slug"] }
+
+ context "when provided from name" do
+ let(:configuration) { { name: "samlwat" } }
+
+ it { is_expected.to eq("samlwat") }
+ end
+
+ context "when not provided" do
+ let(:configuration) { {} }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe "idp_sso_service_url" do
+ subject { result["options"] }
+
+ context "when provided" do
+ let(:configuration) { { idp_sso_service_url: "http://example.com" } }
+
+ it { is_expected.to include("idp_sso_service_url" => "http://example.com") }
+ end
+
+ context "when provided as legacy" do
+ let(:configuration) { { idp_sso_target_url: "http://example.com" } }
+
+ it { is_expected.to include("idp_sso_service_url" => "http://example.com") }
+ end
+ end
+
+ describe "idp_slo_service_url" do
+ subject { result["options"] }
+
+ context "when provided" do
+ let(:configuration) { { idp_slo_service_url: "http://example.com" } }
+
+ it { is_expected.to include("idp_slo_service_url" => "http://example.com") }
+ end
+
+ context "when provided as legacy" do
+ let(:configuration) { { idp_slo_target_url: "http://example.com" } }
+
+ it { is_expected.to include("idp_slo_service_url" => "http://example.com") }
+ end
+ end
+
+ describe "sp_entity_id" do
+ subject { result["options"] }
+
+ context "when provided" do
+ let(:configuration) { { sp_entity_id: "http://example.com" } }
+
+ it { is_expected.to include("sp_entity_id" => "http://example.com") }
+ end
+
+ context "when provided as legacy" do
+ let(:configuration) { { issuer: "http://example.com" } }
+
+ it { is_expected.to include("sp_entity_id" => "http://example.com") }
+ end
+ end
+
+ describe "idp_cert" do
+ let(:idp_cert) { File.read(Rails.root.join("modules/auth_saml/spec/fixtures/idp_cert_plain.txt").to_s) }
+
+ subject { result["options"] }
+
+ context "when provided as single" do
+ let(:configuration) do
+ { idp_cert: }
+ end
+
+ it "formats the certificate" do
+ expect(subject["idp_cert"]).to include("BEGIN CERTIFICATE")
+ expect(subject["idp_cert"]).to eq(OneLogin::RubySaml::Utils.format_cert(idp_cert))
+ end
+ end
+
+ context "when provided already formatted" do
+ let(:configuration) do
+ { idp_cert: OneLogin::RubySaml::Utils.format_cert(idp_cert) }
+ end
+
+ it "uses the certificate as is" do
+ expect(subject["idp_cert"]).to include("BEGIN CERTIFICATE")
+ expect(subject["idp_cert"]).to eq(OneLogin::RubySaml::Utils.format_cert(idp_cert))
+ end
+ end
+
+ context "when provided as multi" do
+ let(:configuration) do
+ {
+ idp_cert_multi: {
+ signing: [idp_cert, idp_cert]
+ }
+ }
+ end
+
+ it "formats the certificate" do
+ expect(subject["idp_cert"]).to include("BEGIN CERTIFICATE")
+ expect(subject["idp_cert"].scan("BEGIN CERTIFICATE").length).to eq(2)
+ formatted = OneLogin::RubySaml::Utils.format_cert(idp_cert)
+ expect(subject["idp_cert"]).to eq("#{formatted}\n#{formatted}")
+ end
+ end
+ end
+
+ describe "attribute mapping" do
+ let(:configuration) { { attribute_statements: } }
+
+ subject { result["options"] }
+
+ context "when provided" do
+ let(:attribute_statements) do
+ {
+ login: "uid",
+ email: %w[email mail],
+ first_name: "givenName",
+ last_name: "sn",
+ uid: "someInternalValue"
+ }
+ end
+
+ it "extracts the mappings" do
+ expect(subject["mapping_login"]).to eq "uid"
+ expect(subject["mapping_mail"]).to eq "email\nmail"
+ expect(subject["mapping_firstname"]).to eq "givenName"
+ expect(subject["mapping_lastname"]).to eq "sn"
+ expect(subject["mapping_uid"]).to eq "someInternalValue"
+ end
+ end
+
+ context "when partially provided" do
+ let(:attribute_statements) do
+ {
+ login: "uid",
+ email: "mail"
+ }
+ end
+
+ it "extracts the mappings" do
+ expect(subject["mapping_login"]).to eq "uid"
+ expect(subject["mapping_mail"]).to eq "mail"
+ expect(subject["mapping_firstname"]).to be_nil
+ expect(subject["mapping_lastname"]).to be_nil
+ expect(subject["mapping_uid"]).to be_nil
+ end
+ end
+
+ context "when not provided" do
+ let(:attribute_statements) { nil }
+
+ it "does not set any security options" do
+ expect(subject["mapping_login"]).to be_nil
+ expect(subject["mapping_mail"]).to be_nil
+ expect(subject["mapping_firstname"]).to be_nil
+ expect(subject["mapping_lastname"]).to be_nil
+ expect(subject["mapping_uid"]).to be_nil
+ end
+ end
+ end
+
+ describe "security" do
+ let(:configuration) { { security: } }
+
+ subject { result["options"] }
+
+ context "when provided" do
+ let(:security) do
+ {
+ authn_requests_signed: true,
+ want_assertions_signed: true,
+ want_assertions_encrypted: true,
+ digest_method: "http://www.w3.org/2001/04/xmlenc#sha256",
+ signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
+ bogus_method: "wat"
+ }
+ end
+
+ it "extracts the security options" do
+ expect(subject).to include(security
+ .slice(:authn_requests_signed, :want_assertions_signed,
+ :want_assertions_encrypted, :digest_method, :signature_method)
+ .stringify_keys)
+
+ expect(subject["authn_requests_signed"]).to be true
+ expect(subject["want_assertions_signed"]).to be true
+ expect(subject["want_assertions_encrypted"]).to be true
+ expect(subject["digest_method"]).to eq("http://www.w3.org/2001/04/xmlenc#sha256")
+ expect(subject["signature_method"]).to eq("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256")
+ expect(subject).not_to include("bogus_method")
+ end
+ end
+
+ context "when not provided" do
+ let(:security) { nil }
+
+ it "does not set any security options" do
+ expect(subject["authn_requests_signed"]).to be_nil
+ expect(subject["want_assertions_signed"]).to be_nil
+ expect(subject["want_assertions_encrypted"]).to be_nil
+ expect(subject["digest_method"]).to be_nil
+ expect(subject["signature_method"]).to be_nil
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/spec/services/saml/providers/create_service_spec.rb b/modules/auth_saml/spec/services/saml/providers/create_service_spec.rb
new file mode 100644
index 000000000000..a148565c4924
--- /dev/null
+++ b/modules/auth_saml/spec/services/saml/providers/create_service_spec.rb
@@ -0,0 +1,36 @@
+#-- 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 "services/base_services/behaves_like_create_service"
+
+RSpec.describe Saml::Providers::CreateService, type: :model do
+ it_behaves_like "BaseServices create service" do
+ let(:factory) { :saml_provider }
+ end
+end
diff --git a/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb b/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb
new file mode 100644
index 000000000000..1c389d585608
--- /dev/null
+++ b/modules/auth_saml/spec/services/saml/providers/set_attributes_service_spec.rb
@@ -0,0 +1,336 @@
+# 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::Providers::SetAttributesService, type: :model do
+ let(:current_user) { build_stubbed(:admin) }
+
+ let(:instance) do
+ described_class.new(user: current_user,
+ model: model_instance,
+ contract_class:,
+ contract_options: {})
+ end
+
+ let(:params) do
+ { options: }
+ end
+ let(:call) { instance.call(params) }
+
+ subject { call.result }
+
+ describe "new instance" do
+ let(:model_instance) { Saml::Provider.new(display_name: "foo") }
+ let(:contract_class) { Saml::Providers::CreateContract }
+
+ describe "default attributes" do
+ let(:options) { {} }
+
+ it "sets all default attributes", :aggregate_failures do
+ expect(subject.display_name).to eq "foo"
+ expect(subject.slug).to eq "saml-foo"
+ expect(subject.creator).to eq(current_user)
+ expect(subject.sp_entity_id).to eq(OpenProject::StaticRouting::StaticUrlHelpers.new.root_url)
+ expect(subject.name_identifier_format).to eq("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress")
+ expect(subject.signature_method).to eq(Saml::Defaults::SIGNATURE_METHODS["RSA SHA-1"])
+ expect(subject.digest_method).to eq(Saml::Defaults::DIGEST_METHODS["SHA-1"])
+
+ expect(subject.mapping_mail).to eq Saml::Defaults::MAIL_MAPPING
+ expect(subject.mapping_firstname).to eq Saml::Defaults::FIRSTNAME_MAPPING
+ expect(subject.mapping_lastname).to eq Saml::Defaults::LASTNAME_MAPPING
+ expect(subject.mapping_uid).to be_blank
+ expect(subject.mapping_login).to eq Saml::Defaults::MAIL_MAPPING
+
+ expect(subject.requested_login_attribute).to eq "mail"
+ expect(subject.requested_mail_attribute).to eq "mail"
+ expect(subject.requested_firstname_attribute).to eq "givenName"
+ expect(subject.requested_lastname_attribute).to eq "sn"
+
+ expect(subject.requested_login_format).to eq "urn:oasis:names:tc:SAML:2.0:attrname-format:basic"
+ expect(subject.requested_mail_format).to eq "urn:oasis:names:tc:SAML:2.0:attrname-format:basic"
+ expect(subject.requested_firstname_format).to eq "urn:oasis:names:tc:SAML:2.0:attrname-format:basic"
+ expect(subject.requested_lastname_format).to eq "urn:oasis:names:tc:SAML:2.0:attrname-format:basic"
+ end
+ end
+
+ describe "SLO URL" do
+ let(:options) do
+ {
+ idp_slo_service_url:
+ }
+ end
+
+ context "when nil" do
+ let(:idp_slo_service_url) { nil }
+
+ it "is valid" do
+ expect(call).to be_success
+ expect(call.errors).to be_empty
+
+ expect(subject.idp_slo_service_url).to be_nil
+ end
+ end
+
+ context "when blank" do
+ let(:idp_slo_service_url) { "" }
+
+ it "is valid" do
+ expect(call).to be_success
+ expect(call.errors).to be_empty
+
+ expect(subject.idp_slo_service_url).to eq ""
+ end
+ end
+
+ context "when not a URL" do
+ let(:idp_slo_service_url) { "foo!" }
+
+ it "is valid" do
+ expect(call).not_to be_success
+ expect(call.errors.details[:idp_slo_service_url])
+ .to contain_exactly({ error: :url, value: idp_slo_service_url })
+ end
+ end
+
+ context "when invalid scheme" do
+ let(:idp_slo_service_url) { "urn:some:info" }
+
+ it "is valid" do
+ expect(call).not_to be_success
+ expect(call.errors.details[:idp_slo_service_url])
+ .to contain_exactly({ error: :url, value: idp_slo_service_url })
+ end
+ end
+
+ context "when valid" do
+ let(:idp_slo_service_url) { "https://foobar.example.com/slo" }
+
+ it "is valid" do
+ expect(call).to be_success
+ expect(call.errors).to be_empty
+
+ expect(subject.idp_slo_service_url).to eq idp_slo_service_url
+ end
+ end
+ end
+
+ describe "IDP certificate" do
+ let(:options) do
+ {
+ idp_cert: certificate
+ }
+ end
+
+ context "with a valid certificate" do
+ let(:certificate) { CertificateHelper.valid_certificate.to_pem }
+
+ it "assigns the certificate" do
+ expect(call).to be_success
+ expect(call.errors).to be_empty
+
+ expect(subject.idp_cert).to eq CertificateHelper.valid_certificate.to_pem
+ end
+ end
+
+ context "with a valid certificate, not in pem format" do
+ let(:certificate) { CertificateHelper.valid_certificate.to_pem.lines[1...-1].join }
+
+ it "assigns the certificate" do
+ expect(call).to be_success
+ expect(call.errors).to be_empty
+
+ expect(subject.idp_cert).to eq CertificateHelper.valid_certificate.to_pem.strip
+ end
+ end
+
+ context "with two certificates, one expired" do
+ let(:certificate) do
+ "#{CertificateHelper.valid_certificate.to_pem}\n#{CertificateHelper.expired_certificate.to_pem}"
+ end
+
+ it "assigns the certificate" do
+ expect(call).to be_success
+ expect(call.errors).to be_empty
+
+ expect(subject.idp_cert).to eq certificate
+ end
+ end
+
+ context "with an invalid certificate" do
+ let(:certificate) { CertificateHelper.expired_certificate.to_pem }
+
+ it "assigns the certificate" do
+ expect(call).not_to be_success
+ expect(call.errors.details[:idp_cert]).to contain_exactly({ error: :certificate_expired })
+ end
+ end
+ end
+
+ describe "certificate and private key" do
+ let(:options) do
+ {
+ certificate: given_certificate,
+ private_key: given_private_key
+ }
+ end
+
+ context "with a valid certificate pair" do
+ let(:given_certificate) { CertificateHelper.valid_certificate.to_pem }
+ let(:given_private_key) { CertificateHelper.private_key.private_to_pem }
+
+ it "assigns the certificate" do
+ expect(call).to be_success
+ expect(call.errors).to be_empty
+
+ expect(subject.certificate).to eq given_certificate.strip
+ expect(subject.private_key).to eq given_private_key.strip
+ end
+ end
+
+ context "with an invalid certificate" do
+ let(:given_certificate) { CertificateHelper.expired_certificate.to_pem }
+ let(:given_private_key) { nil }
+
+ it "results in an error" do
+ expect(call).not_to be_success
+ expect(call.errors.details[:certificate]).to contain_exactly({ error: :certificate_expired })
+ expect(call.errors.details[:private_key]).to contain_exactly({ error: :blank })
+ end
+ end
+
+ context "with a mismatched certificate" do
+ let(:given_certificate) { CertificateHelper.mismatched_certificate.to_pem }
+ let(:given_private_key) { CertificateHelper.private_key.private_to_pem }
+
+ it "results in an error" do
+ expect(call).not_to be_success
+ expect(call.errors.details[:private_key]).to contain_exactly({ error: :unmatched_private_key })
+ end
+ end
+ end
+
+ describe "mapping" do
+ let(:options) do
+ {
+ mapping_mail: "mail\n whitespace \nfoo",
+ mapping_firstname: "name\nsn",
+ mapping_lastname: "hello ",
+ mapping_uid: "something"
+ }
+ end
+
+ it "assigns the given and default values", :aggregate_failures do
+ expect(call).to be_success
+ expect(call.errors).to be_empty
+
+ expect(subject.mapping_mail).to eq "mail\nwhitespace\nfoo"
+ expect(subject.mapping_firstname).to eq "name\nsn"
+ expect(subject.mapping_lastname).to eq "hello"
+ expect(subject.mapping_uid).to eq "something"
+
+ expect(subject.mapping_login).to eq Saml::Defaults::MAIL_MAPPING
+ end
+ end
+
+ describe "want_assertions_signed" do
+ context "when provided" do
+ let(:options) { { want_assertions_signed: true } }
+
+ it "assigns the value" do
+ expect(call).to be_success
+ expect(call.errors).to be_empty
+
+ expect(subject.want_assertions_signed).to be true
+ end
+ end
+
+ context "when not provided" do
+ let(:options) { {} }
+
+ it "assigns the default value" do
+ expect(call).to be_success
+ expect(call.errors).to be_empty
+
+ expect(subject.want_assertions_signed).to be false
+ end
+ end
+ end
+
+ describe "want_assertions_encrypted" do
+ context "when provided" do
+ let(:options) { { want_assertions_encrypted: true } }
+
+ it "assigns the value" do
+ expect(call).to be_success
+ expect(call.errors).to be_empty
+
+ expect(subject.want_assertions_encrypted).to be true
+ end
+ end
+
+ context "when not provided" do
+ let(:options) { {} }
+
+ it "assigns the default value" do
+ expect(call).to be_success
+ expect(call.errors).to be_empty
+
+ expect(subject.want_assertions_encrypted).to be false
+ end
+ end
+ end
+
+ describe "authn_requests_signed" do
+ context "when provided" do
+ let(:options) { { authn_requests_signed: true } }
+
+ it "assigns the value" do
+ expect(call).to be_success
+ expect(call.errors).to be_empty
+
+ expect(subject.authn_requests_signed).to be true
+ end
+ end
+
+ context "when not provided" do
+ let(:options) { {} }
+
+ it "assigns the default value" do
+ expect(call).to be_success
+ expect(call.errors).to be_empty
+
+ expect(subject.authn_requests_signed).to be false
+ end
+ end
+ end
+ end
+end
diff --git a/modules/auth_saml/spec/services/saml/providers/update_service_spec.rb b/modules/auth_saml/spec/services/saml/providers/update_service_spec.rb
new file mode 100644
index 000000000000..bd0b8df595d3
--- /dev/null
+++ b/modules/auth_saml/spec/services/saml/providers/update_service_spec.rb
@@ -0,0 +1,36 @@
+#-- 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 "services/base_services/behaves_like_update_service"
+
+RSpec.describe Saml::Providers::UpdateService, type: :model do
+ it_behaves_like "BaseServices update service" do
+ let(:factory) { :saml_provider }
+ end
+end
diff --git a/modules/auth_saml/spec/spec_helper.rb b/modules/auth_saml/spec/spec_helper.rb
index 4351818bd68d..4ee67b06a691 100644
--- a/modules/auth_saml/spec/spec_helper.rb
+++ b/modules/auth_saml/spec/spec_helper.rb
@@ -1,2 +1,3 @@
# -- load spec_helper from OpenProject core
require "spec_helper"
+require_relative "support/certificate_helper"
diff --git a/modules/auth_saml/spec/support/certificate_helper.rb b/modules/auth_saml/spec/support/certificate_helper.rb
new file mode 100644
index 000000000000..85bf1efe6f28
--- /dev/null
+++ b/modules/auth_saml/spec/support/certificate_helper.rb
@@ -0,0 +1,65 @@
+module CertificateHelper
+ module_function
+
+ def private_key
+ @private_key ||= OpenSSL::PKey::RSA.new(1024)
+ end
+
+ def non_padded_string(certificate_name)
+ public_send(certificate_name)
+ .to_pem
+ .gsub("-----BEGIN CERTIFICATE-----", "")
+ .gsub("-----END CERTIFICATE-----", "")
+ .delete("\n")
+ .strip
+ end
+
+ def valid_certificate
+ @valid_certificate ||= begin
+ name = OpenSSL::X509::Name.parse "/CN=valid-testing"
+ cert = OpenSSL::X509::Certificate.new
+ cert.version = 2
+ cert.serial = 1234
+
+ cert.not_before = Time.current
+ cert.not_after = Time.current + 606024364.251
+ cert.public_key = private_key.public_key
+ cert.subject = name
+ cert.issuer = name
+ cert.sign private_key, OpenSSL::Digest.new("SHA1")
+ end
+ end
+
+ def expired_certificate
+ @expired_certificate ||= begin
+ name = OpenSSL::X509::Name.parse "/CN=expired-testing"
+ cert = OpenSSL::X509::Certificate.new
+ cert.version = 2
+ cert.serial = 1234
+
+ cert.not_before = 2.years.ago
+ cert.not_after = 30.days.ago
+ cert.public_key = private_key.public_key
+ cert.subject = name
+ cert.issuer = name
+ cert.sign private_key, OpenSSL::Digest.new("SHA1")
+ end
+ end
+
+ def mismatched_certificate
+ @mismatched_certificate ||= begin
+ name = OpenSSL::X509::Name.parse "/CN=mismatched-testing"
+ cert = OpenSSL::X509::Certificate.new
+ cert.version = 2
+ cert.serial = 1234
+
+ key = OpenSSL::PKey::RSA.new(1024)
+ cert.not_before = Time.current
+ cert.not_after = Time.current + 606024364.251
+ cert.public_key = key.public_key
+ cert.subject = name
+ cert.issuer = name
+ cert.sign key, OpenSSL::Digest.new("SHA1")
+ end
+ end
+end
diff --git a/modules/costs/config/locales/en.yml b/modules/costs/config/locales/en.yml
index f8a7e05c2ad9..a143a2933008 100644
--- a/modules/costs/config/locales/en.yml
+++ b/modules/costs/config/locales/en.yml
@@ -125,6 +125,7 @@ en:
label_rate: "Rate"
label_rate_plural: "Rates"
label_status_finished: "Finished"
+ label_show: "Show"
label_units: "Cost units"
label_user: "User"
label_until: "until"
diff --git a/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb b/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb
index dbf3136d1173..ac436d6418fe 100644
--- a/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb
+++ b/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb
@@ -55,7 +55,7 @@ def destroy
private
def check_ee
- unless EnterpriseToken.allows_to?(:openid_providers)
+ unless EnterpriseToken.allows_to?(:sso_auth_providers)
render template: "/openid_connect/providers/upsale"
false
end
diff --git a/modules/openid_connect/lib/open_project/openid_connect/engine.rb b/modules/openid_connect/lib/open_project/openid_connect/engine.rb
index 12a2cb99bfb4..345651d2967e 100644
--- a/modules/openid_connect/lib/open_project/openid_connect/engine.rb
+++ b/modules/openid_connect/lib/open_project/openid_connect/engine.rb
@@ -16,7 +16,7 @@ class Engine < ::Rails::Engine
:openid_connect_providers_path,
parent: :authentication,
caption: ->(*) { I18n.t("openid_connect.menu_title") },
- enterprise_feature: "openid_providers"
+ enterprise_feature: "sso_auth_providers"
end
assets %w(
diff --git a/modules/openid_connect/spec/controllers/providers_controller_spec.rb b/modules/openid_connect/spec/controllers/providers_controller_spec.rb
index 8d1870f35eb4..493b4d50d543 100644
--- a/modules/openid_connect/spec/controllers/providers_controller_spec.rb
+++ b/modules/openid_connect/spec/controllers/providers_controller_spec.rb
@@ -51,7 +51,7 @@
end
end
- context "with an EE token", with_ee: %i[openid_providers] do
+ context "with an EE token", with_ee: %i[sso_auth_providers] do
before do
login_as user
end
diff --git a/modules/openid_connect/spec/requests/openid_connect_spec.rb b/modules/openid_connect/spec/requests/openid_connect_spec.rb
index c0405e226ac8..808902e6aad0 100644
--- a/modules/openid_connect/spec/requests/openid_connect_spec.rb
+++ b/modules/openid_connect/spec/requests/openid_connect_spec.rb
@@ -35,7 +35,7 @@
RSpec.describe "OpenID Connect", :skip_2fa_stage, # Prevent redirects to 2FA stage
type: :rails_request,
- with_ee: %i[openid_providers] do
+ with_ee: %i[sso_auth_providers] do
let(:host) { OmniAuth::OpenIDConnect::Heroku.new("foo", {}).host }
let(:user_info) do
{
diff --git a/modules/storages/app/components/storages/admin/storage_row_component.html.erb b/modules/storages/app/components/storages/admin/storage_row_component.html.erb
index d44b4819eb27..df912ff06deb 100644
--- a/modules/storages/app/components/storages/admin/storage_row_component.html.erb
+++ b/modules/storages/app/components/storages/admin/storage_row_component.html.erb
@@ -4,7 +4,7 @@
concat(render(Primer::Beta::Link.new(href: url_helpers.edit_admin_settings_storage_path(storage), font_weight: :bold, mr: 1, data: { 'test-selector': 'storage-name' })) { storage.name })
unless storage.configured?
- concat(render(Primer::Beta::Label.new(scheme: :attention, test_selector: "label-incomplete")) { I18n.t('storages.label_incomplete') })
+ concat(render(Primer::Beta::Label.new(scheme: :attention, test_selector: "label-incomplete")) { I18n.t(:label_incomplete) })
end
if storage.health_unhealthy?
diff --git a/modules/storages/app/components/storages/admin/storage_view_information.rb b/modules/storages/app/components/storages/admin/storage_view_information.rb
index d019b95d3835..a43f41d9f61a 100644
--- a/modules/storages/app/components/storages/admin/storage_view_information.rb
+++ b/modules/storages/app/components/storages/admin/storage_view_information.rb
@@ -55,9 +55,9 @@ def configuration_check_label_for(*configs)
return if storage.configuration_checks.values.none?
if storage.configuration_checks.slice(*configs.map(&:to_sym)).values.all?
- status_label(I18n.t("storages.label_completed"), scheme: :success, test_selector: "label-#{configs.join('-')}-status")
+ status_label(I18n.t(:label_completed), scheme: :success, test_selector: "label-#{configs.join('-')}-status")
else
- status_label(I18n.t("storages.label_incomplete"), scheme: :attention, test_selector: "label-#{configs.join('-')}-status")
+ status_label(I18n.t(:label_incomplete), scheme: :attention, test_selector: "label-#{configs.join('-')}-status")
end
end
@@ -74,7 +74,7 @@ def automatically_managed_project_folders_status_label
if storage.automatic_management_enabled?
status_label(I18n.t("storages.label_active"), scheme: :success, test_selector:)
elsif storage.automatic_management_unspecified?
- status_label(I18n.t("storages.label_incomplete"), scheme: :attention, test_selector:)
+ status_label(I18n.t(:label_incomplete), scheme: :attention, test_selector:)
else
status_label(I18n.t("storages.label_inactive"), scheme: :secondary, test_selector:)
end
diff --git a/modules/storages/config/locales/en.yml b/modules/storages/config/locales/en.yml
index 0471043a59f9..e568aa618f07 100644
--- a/modules/storages/config/locales/en.yml
+++ b/modules/storages/config/locales/en.yml
@@ -287,7 +287,6 @@ en:
label_active: Active
label_add_new_storage: Add new storage
label_automatic_folder: New folder with automatically managed permissions
- label_completed: Completed
label_creation_time: Creation time
label_creator: Creator
label_delete_storage: Delete storage
@@ -299,7 +298,6 @@ en:
label_file_storage: File storage
label_host: Host URL
label_inactive: Inactive
- label_incomplete: Incomplete
label_managed_project_folders:
application_password: Application password
automatically_managed_folders: Automatically managed folders
diff --git a/modules/storages/spec/requests/project_storages_open_spec.rb b/modules/storages/spec/requests/project_storages_open_spec.rb
index e3b6c2cd37e8..4baeb7b0f06c 100644
--- a/modules/storages/spec/requests/project_storages_open_spec.rb
+++ b/modules/storages/spec/requests/project_storages_open_spec.rb
@@ -75,7 +75,7 @@
get route, {}, { "HTTP_ACCEPT" => "text/vnd.turbo-stream.html" }
expect(last_response).to have_http_status(:ok)
- expect(last_response.body).to eq("\n \n \n