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 %> <% 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 @@ +
+
+ <%= f.text_field :name, required: true, disable: f.object.persisted?, container_class: '-middle' %> +
+ +
+ <%= f.text_field :display_name, required: true, container_class: '-middle' %> +
+ +
+ <%= f.text_field :sp_entity_id, required: false, container_class: '-middle' %> +
+ +
+ <%= f.text_field :idp_sso_service_url, required: true, container_class: '-xwide' %> +
+ +
+ <%= f.text_field :idp_slo_service_url, required: false, container_class: '-xwide' %> +
+ +
+ <%= f.text_field :idp_cert_fingerprint, + required: false, + container_class: '-xwide' %> +
+ +
+ <%= f.text_area :idp_cert, required: false, rows: 10, columns: 20, container_class: '-xwide' %> +
+ +
+ <%= f.check_box :limit_self_registration, required: false, container_class: '-middle' %> +
+ <%= I18n.t('openid_connect.setting_instructions.limit_self_registration') %> +
+
+ +
+ <%= f.text_field :name_identifier_format, required: false, container_class: '-xwide' %> +
+
+ +
+ <%= t('saml.providers.attribute_mapping') %> +

<%= t 'saml.providers.attribute_mapping_text' %>

+ +
+ <%= f.text_field 'login_mapping', + required: true, + size: 20, + container_class: '-middle' %> + + <%= t('ldap_auth_sources.attribute_texts.login_map') %> + +
+
+ <%= f.text_field 'first_name_mapping', + size: 20, + container_class: '-middle' %> + + <%= t('ldap_auth_sources.attribute_texts.generic_map', attribute: ApplicationRecord.human_attribute_name(:firstname)) %> + +
+
+ <%= f.text_field 'last_name_mapping', + size: 20, + container_class: '-middle' %> + + <%= t('ldap_auth_sources.attribute_texts.generic_map', attribute: ApplicationRecord.human_attribute_name(:lastname)) %> + +
+
+ <%= f.text_field 'email_mapping', + size: 20, + placeholder: 'mail', + container_class: '-middle' %> + + <%= t('ldap_auth_sources.attribute_texts.generic_map', attribute: ApplicationRecord.human_attribute_name(:mail)) %> + +
+
+ +
+ <%= t('saml.providers.request_attributes.title') %> +

<%= t 'saml.providers.request_attributes.legend' %>

+ + <% @provider.request_attributes.each do |request| %> + <% base_key = "saml_request_#{request['name']}" %> +
+ <%= styled_label_tag "#{base_key}_name", t('saml.providers.requested_attributes.name') %> +
+ <%= text_field_tag 'saml_provider[request_attributes][][name]', + request['name'], + id: "#{base_key}_name" + %> +
+
+
+ <%= styled_label_tag "#{base_key}_format", t('saml.providers.requested_attributes.format') %> +
+ <%= text_field_tag 'saml_provider[request_attributes][][name_format]', + request['name_format'], + id: "#{base_key}_format" + %> +
+
+
+ <% end %> +
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\n") # rubocop:disable Layout/LineLength + expect(last_response.body).to be_html_eql("\n \n\n\n") # rubocop:disable Layout/LineLength end end end @@ -85,10 +85,8 @@ before do Storages::Peripherals::Registry.stub( - "nextcloud.queries.file_info", ->(_) do - ServiceResult.failure(result: code, - errors: Storages::StorageError.new(code:)) - end + "nextcloud.queries.file_info", + ->(_) { ServiceResult.failure(result: code, errors: Storages::StorageError.new(code:)) } ) end @@ -100,9 +98,10 @@ get route, {}, { "HTTP_ACCEPT" => "text/html" } expect(last_response).to have_http_status(:found) - expect(last_response.headers["Location"]).to eq ( - "http://#{Setting.host_name}/oauth_clients/#{storage.oauth_client.client_id}/ensure_connection?destination_url=http%3A%2F%2F#{CGI.escape(Setting.host_name)}%2Fprojects%2F#{project.identifier}%2Fproject_storages%2F#{project_storage.id}%2Fopen&storage_id=#{storage.id}" - ) + expect(last_response.headers["Location"]) + .to eq("http://#{Setting.host_name}/oauth_clients/#{storage.oauth_client.client_id}/ensure_connection?" \ + "destination_url=http%3A%2F%2F#{CGI.escape(Setting.host_name)}%2Fprojects%2F#{project.identifier}" \ + "%2Fproject_storages%2F#{project_storage.id}%2Fopen&storage_id=#{storage.id}") end end @@ -111,7 +110,7 @@ get route, {}, { "HTTP_ACCEPT" => "text/html" } expect(last_response).to have_http_status(:found) - expect(last_response.headers["Location"]).to eq ("http://#{Setting.host_name}/projects/#{project.identifier}") + expect(last_response.headers["Location"]).to eq("http://#{Setting.host_name}/projects/#{project.identifier}") expect(last_request.session["flash"]["flashes"]) .to eq({ "modal" => { @@ -142,7 +141,7 @@ get route, {}, { "HTTP_ACCEPT" => "text/html" } expect(last_response).to have_http_status(:found) - expect(last_response.headers["Location"]).to eq ("http://#{Setting.host_name}/projects/#{project.identifier}") + expect(last_response.headers["Location"]).to eq("http://#{Setting.host_name}/projects/#{project.identifier}") expect(last_request.session["flash"]["flashes"]) .to eq({ "modal" => { diff --git a/spec/requests/openid_google_provider_callback_spec.rb b/spec/requests/openid_google_provider_callback_spec.rb index 9dc3e213ae57..5f86a8ac23dc 100644 --- a/spec/requests/openid_google_provider_callback_spec.rb +++ b/spec/requests/openid_google_provider_callback_spec.rb @@ -29,7 +29,7 @@ require "spec_helper" require "rack/test" -RSpec.describe "OpenID Google provider callback", with_ee: %i[openid_providers] do +RSpec.describe "OpenID Google provider callback", with_ee: %i[sso_auth_providers] do include Rack::Test::Methods include API::V3::Utilities::PathHelper diff --git a/spec/support/shared/with_settings.rb b/spec/support/shared/with_settings.rb index a49c56bbcb2e..d4286554d096 100644 --- a/spec/support/shared/with_settings.rb +++ b/spec/support/shared/with_settings.rb @@ -41,9 +41,11 @@ def aggregate_mocked_settings(example, settings) RSpec.shared_context "with settings reset" do shared_let(:definitions_before) { Settings::Definition.all.dup } - def reset(setting) + def reset(setting, **definitions) + definitions = Settings::Definition::DEFINITIONS[setting] if definitions.empty? + Settings::Definition.all.delete(setting) - Settings::Definition.add(setting, **Settings::Definition::DEFINITIONS[setting]) + Settings::Definition.add(setting, **definitions) end def stub_configuration_yml