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 293aa2d79ed5..01cbc672c596 100644 --- a/app/components/op_primer/border_box_table_component.html.erb +++ b/app/components/op_primer/border_box_table_component.html.erb @@ -46,11 +46,11 @@ See COPYRIGHT and LICENSE files for more details. 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)) + else + rows.each do |row| + component.with_row(scheme: :default) do + render(row_class.new(row:, table: self)) + end end end end diff --git a/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb b/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb new file mode 100644 index 000000000000..edbaf98b5c7d --- /dev/null +++ b/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb @@ -0,0 +1,71 @@ +# 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. +#++ + +class MigrateOidcSettingsToProviders < ActiveRecord::Migration[7.1] + def up + providers = Hash(Setting.plugin_openproject_openid_connect).with_indifferent_access[:providers] + return if providers.blank? + + providers.each do |name, configuration| + configuration.delete(:name) + migrate_provider!(name, configuration) + end + end + + def down + # This migration does not yet remove Setting.plugin_openproject_openid_connect + # so it can be retried. + end + + private + + def migrate_provider!(name, configuration) + Rails.logger.debug { "Trying to migrate OpenID provider #{name} from previous settings format..." } + call = ::OpenIDConnect::SyncService.new(name, configuration).call + + if call.success + Rails.logger.debug { <<~SUCCESS } + Successfully migrated OpenID provider #{name} from previous settings format. + You can now manage this provider in the new administrative UI within OpenProject under + the "Administration -> Authentication -> OpenID providers" section. + SUCCESS + else + raise <<~ERROR + Failed to create or update OpenID 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 OpenID provider setting and discard them instead, you can use our documentation + to unset any previous OpenID provider settings: + https://www.openproject.org/docs/system-admin-guide/authentication/openid-providers/ + ERROR + end + end +end diff --git a/modules/auth_plugins/app/views/hooks/login/_providers.html.erb b/modules/auth_plugins/app/views/hooks/login/_providers.html.erb index c3e34bf4f857..0b27df729c52 100644 --- a/modules/auth_plugins/app/views/hooks/login/_providers.html.erb +++ b/modules/auth_plugins/app/views/hooks/login/_providers.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% OpenProject::Plugins::AuthPlugin.providers.each do |pro| %> +<% OpenProject::Plugins::AuthPlugin.providers.each do |provider| %> <% opts = { script_name: OpenProject::Configuration.rails_relative_url_root } @@ -36,8 +36,8 @@ See COPYRIGHT and LICENSE files for more details. end %> - <%= pro[:display_name] || pro[:name] %> + href="<%= omni_auth_start_path(provider[:name], opts) %>" + class="auth-provider auth-provider-<%= provider[:name] %> <%= provider[:icon] ? 'auth-provider--imaged' : '' %> button"> + <%= provider[:display_name] || provider[:name] %> <% end %> diff --git a/modules/auth_plugins/app/views/hooks/login/_providers_css.html.erb b/modules/auth_plugins/app/views/hooks/login/_providers_css.html.erb index 88178a25f6ff..519c8199237a 100644 --- a/modules/auth_plugins/app/views/hooks/login/_providers_css.html.erb +++ b/modules/auth_plugins/app/views/hooks/login/_providers_css.html.erb @@ -27,17 +27,17 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% OpenProject::Plugins::AuthPlugin.providers.each do |pro| %> - <% if pro[:icon] %> +<% OpenProject::Plugins::AuthPlugin.providers.each do |provider| %> + <% if provider[:icon] %> <% end -%> diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index ab8a43a11fe4..7574bf185024 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -85,7 +85,7 @@ def create def update call = Saml::Providers::UpdateService .new(model: @provider, user: User.current) - .call(options: update_params) + .call(update_params) if call.success? flash[:notice] = I18n.t(:notice_successful_update) unless @edit_mode diff --git a/modules/auth_saml/spec/features/administration/saml_crud_spec.rb b/modules/auth_saml/spec/features/administration/saml_crud_spec.rb index 2df6ed5f2512..b1685cbe8362 100644 --- a/modules/auth_saml/spec/features/administration/saml_crud_spec.rb +++ b/modules/auth_saml/spec/features/administration/saml_crud_spec.rb @@ -98,7 +98,7 @@ 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_login).to eq "login\r\nmail" expect(provider.mapping_mail).to eq "mail" expect(provider.mapping_firstname).to eq "myName" expect(provider.mapping_lastname).to eq "myLastName" diff --git a/modules/openid_connect/app/assets/images/openid_connect/auth_provider-custom.png b/modules/openid_connect/app/assets/images/openid_connect/auth_provider-custom.png new file mode 100644 index 000000000000..a3f8d7d23978 Binary files /dev/null and b/modules/openid_connect/app/assets/images/openid_connect/auth_provider-custom.png differ diff --git a/modules/openid_connect/app/assets/images/openid_connect/auth_provider-heroku.png b/modules/openid_connect/app/assets/images/openid_connect/auth_provider-heroku.png deleted file mode 100644 index b500b38166c8..000000000000 Binary files a/modules/openid_connect/app/assets/images/openid_connect/auth_provider-heroku.png and /dev/null differ diff --git a/modules/openid_connect/app/components/openid_connect/providers/base_form.rb b/modules/openid_connect/app/components/openid_connect/providers/base_form.rb new file mode 100644 index 000000000000..6a727f4162bd --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/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 OpenIDConnect + module Providers + class BaseForm < ApplicationForm + attr_reader :provider + + def initialize(provider:) + super() + @provider = provider + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/client_details_form.rb b/modules/openid_connect/app/components/openid_connect/providers/client_details_form.rb new file mode 100644 index 000000000000..3c7703b713d0 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/client_details_form.rb @@ -0,0 +1,53 @@ +#-- 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 OpenIDConnect + module Providers + class ClientDetailsForm < BaseForm + form do |f| + %i[client_id client_secret].each do |attr| + f.text_field( + name: attr, + label: I18n.t("activemodel.attributes.openid_connect/provider.#{attr}"), + caption: I18n.t("openid_connect.instructions.#{attr}"), + disabled: provider.seeded_from_env?, + required: true, + input_width: :large + ) + end + f.check_box( + name: :limit_self_registration, + label: I18n.t("activemodel.attributes.openid_connect/provider.limit_self_registration"), + caption: I18n.t("openid_connect.instructions.limit_self_registration"), + disabled: provider.seeded_from_env?, + required: true + ) + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/metadata_details_form.rb b/modules/openid_connect/app/components/openid_connect/providers/metadata_details_form.rb new file mode 100644 index 000000000000..49702255ae70 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/metadata_details_form.rb @@ -0,0 +1,45 @@ +#-- 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 OpenIDConnect + module Providers + class MetadataDetailsForm < BaseForm + form do |f| + OpenIDConnect::Provider::DISCOVERABLE_ATTRIBUTES_ALL.each do |attr| + f.text_field( + name: attr, + label: I18n.t("activemodel.attributes.openid_connect/provider.#{attr}"), + disabled: provider.seeded_from_env?, + required: OpenIDConnect::Provider::DISCOVERABLE_ATTRIBUTES_MANDATORY.include?(attr), + input_width: :large + ) + end + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/metadata_options_form.rb b/modules/openid_connect/app/components/openid_connect/providers/metadata_options_form.rb new file mode 100644 index 000000000000..0e82e822386d --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/metadata_options_form.rb @@ -0,0 +1,58 @@ +#-- 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 OpenIDConnect + module Providers + class MetadataOptionsForm < BaseForm + form do |f| + f.radio_button_group( + name: "metadata", + scope_name_to_model: false, + disabled: provider.seeded_from_env?, + visually_hide_label: true + ) do |radio_group| + radio_group.radio_button( + value: "none", + checked: @provider.metadata_url.blank?, + label: I18n.t("openid_connect.settings.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("openid_connect.settings.metadata_url"), + disabled: provider.seeded_from_env?, + data: { "show-when-value-selected-target": "cause" } + ) + end + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/metadata_url_form.rb b/modules/openid_connect/app/components/openid_connect/providers/metadata_url_form.rb new file mode 100644 index 000000000000..b18a185536cf --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/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 OpenIDConnect + module Providers + class MetadataUrlForm < BaseForm + form do |f| + f.text_field( + name: :metadata_url, + label: I18n.t("openid_connect.settings.endpoint_url"), + required: false, + disabled: provider.seeded_from_env?, + caption: I18n.t("openid_connect.instructions.endpoint_url"), + input_width: :xlarge + ) + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/name_input_and_tenant_form.rb b/modules/openid_connect/app/components/openid_connect/providers/name_input_and_tenant_form.rb new file mode 100644 index 000000000000..16d73ccd3f15 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/name_input_and_tenant_form.rb @@ -0,0 +1,54 @@ +#-- 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 OpenIDConnect + module Providers + class NameInputAndTenantForm < BaseForm + form do |f| + f.hidden(name: :oidc_provider, value: provider.oidc_provider) + f.text_field( + name: :display_name, + label: I18n.t("activemodel.attributes.openid_connect/provider.display_name"), + required: true, + disabled: provider.seeded_from_env?, + caption: I18n.t("openid_connect.instructions.display_name"), + input_width: :medium + ) + f.text_field( + name: :tenant, + label: I18n.t("activemodel.attributes.openid_connect/provider.tenant"), + required: true, + disabled: provider.seeded_from_env?, + value: provider.tenant || "common", + caption: I18n.t("openid_connect.instructions.tenant").html_safe, + input_width: :medium + ) + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/name_input_form.rb b/modules/openid_connect/app/components/openid_connect/providers/name_input_form.rb new file mode 100644 index 000000000000..68728a4f46d3 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/name_input_form.rb @@ -0,0 +1,45 @@ +#-- 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 OpenIDConnect + module Providers + class NameInputForm < BaseForm + form do |f| + f.hidden(name: :oidc_provider, value: provider.oidc_provider) + f.text_field( + name: :display_name, + label: I18n.t("activemodel.attributes.openid_connect/provider.display_name"), + required: true, + disabled: provider.seeded_from_env?, + caption: I18n.t("openid_connect.instructions.display_name"), + input_width: :medium + ) + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/row_component.rb b/modules/openid_connect/app/components/openid_connect/providers/row_component.rb index 220b156153cd..027d9c911579 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/row_component.rb +++ b/modules/openid_connect/app/components/openid_connect/providers/row_component.rb @@ -1,15 +1,36 @@ module OpenIDConnect module Providers - class RowComponent < ::RowComponent + 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 - link_to( - provider.display_name || provider.name, - url_for(action: :edit, id: provider.id) - ) + link = render( + Primer::Beta::Link.new( + href: url_for(action: :edit, id: provider.id), + font_weight: :bold, + mr: 1 + ) + ) { provider.display_name } + if !provider.configured? + link.concat( + render(Primer::Beta::Label.new(scheme: :attention)) { I18n.t(:label_incomplete) } + ) + end + link + end + + def type + I18n.t("openid_connect.providers.#{provider.oidc_provider}.name") end def row_css_class @@ -19,28 +40,20 @@ def row_css_class ].join(" ") end - ### - def button_links - [edit_link, delete_link] + [] + end + + def users + User.where("identity_url LIKE ?", "#{provider.slug}%").count.to_s 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) - ) + def creator + helpers.avatar(provider.creator, size: :mini, hide_name: false) end - def delete_link - 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) - ) + def created_at + helpers.format_time provider.created_at end end end diff --git a/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.html.erb b/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.html.erb new file mode 100644 index 000000000000..5339d40ecdc1 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.html.erb @@ -0,0 +1,41 @@ +<%= + primer_form_with( + id: "openid-connect-providers-edit-form", + model: provider, + url:, + method: form_method, + data: { turbo: true, turbo_stream: true } + ) do |form| + flex_layout do |flex| + if @heading + flex.with_row do + render(Primer::Beta::Text.new(tag: :p, mb: 4, font_weight: :bold)) 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(OpenIDConnect::Providers::SubmitOrCancelForm.new( + form, + provider:, + submit_button_options: { label: button_label }, + cancel_button_options: { hidden: edit_mode } + )) + end + end + end +%> diff --git a/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb b/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb new file mode 100644 index 000000000000..e1da600bae54 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb @@ -0,0 +1,78 @@ +# 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 OpenIDConnect::Providers::Sections + class FormComponent < ::Saml::Providers::Sections::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? + openid_connect_providers_path(edit_state:, edit_mode:, next_edit_state:) + else + openid_connect_provider_path(edit_state:, edit_mode:, next_edit_state:, id: provider.id) + 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/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.html.erb b/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.html.erb new file mode 100644 index 000000000000..efe3f6dd7712 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.html.erb @@ -0,0 +1,56 @@ +<%= + primer_form_with( + model: provider, + id: "openid-connect-providers-edit-form", + url: openid_connect_provider_path(provider, edit_mode:, next_edit_state: :metadata_details), + data: { + controller: "show-when-value-selected", + turbo: true, + turbo_stream: true, + }, + method: :put, + ) do |form| + flex_layout do |flex| + unless edit_mode + flex.with_row do + render(Primer::Alpha::Banner.new(mb: 2, scheme: :warning, icon: :alert)) do + t("openid_connect.providers.section_texts.metadata_form_banner") + end + end + end + + flex.with_row do + render(Primer::Beta::Text.new(tag: :p, font_weight: :bold)) { + I18n.t("openid_connect.providers.label_metadata") + } + end + + flex.with_row do + render(Primer::Beta::Text.new(tag: :p)) { + I18n.t("openid_connect.providers.section_texts.metadata_form_description") + } + end + + flex.with_row do + render(OpenIDConnect::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(OpenIDConnect::Providers::MetadataUrlForm.new(form, provider:)) + end + + flex.with_row(mt: 4) do + render(OpenIDConnect::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/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.rb b/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.rb new file mode 100644 index 000000000000..74e4ed983db8 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/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 OpenIDConnect::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/openid_connect/app/components/openid_connect/providers/sections/show_component.html.erb b/modules/openid_connect/app/components/openid_connect/providers/sections/show_component.html.erb new file mode 100644 index 000000000000..f0a6c588f4fc --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/sections/show_component.html.erb @@ -0,0 +1,45 @@ +<%= + grid_layout('op-saml-view-row', + tag: :div, + test_selector: "openid_connect_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: url_for(action: :edit, id: provider.id, 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/openid_connect/app/components/openid_connect/providers/sections/show_component.rb b/modules/openid_connect/app/components/openid_connect/providers/sections/show_component.rb new file mode 100644 index 000000000000..630b15558b5f --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/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 OpenIDConnect::Providers::Sections + class ShowComponent < ::Saml::Providers::Sections::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/openid_connect/app/components/openid_connect/providers/submit_or_cancel_form.rb b/modules/openid_connect/app/components/openid_connect/providers/submit_or_cancel_form.rb new file mode 100644 index 000000000000..d04799752bb6 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/submit_or_cancel_form.rb @@ -0,0 +1,85 @@ +#-- 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 OpenIDConnect + 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, + label: I18n.t("button_cancel") + } + end + + def back_link + if @provider.new_record? + OpenProject::StaticRouting::StaticRouter.new.url_helpers.openid_connect_providers_path + else + OpenProject::StaticRouting::StaticRouter.new.url_helpers.edit_openid_connect_provider_path(@provider) + end + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/table_component.rb b/modules/openid_connect/app/components/openid_connect/providers/table_component.rb index 8b4225548255..c132e72cd3e1 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/table_component.rb +++ b/modules/openid_connect/app/components/openid_connect/providers/table_component.rb @@ -1,12 +1,24 @@ module OpenIDConnect module Providers - class TableComponent < ::TableComponent - columns :name + class TableComponent < ::OpPrimer::BorderBoxTableComponent + columns :name, :type, :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 has_actions? + true + end + def sortable? false end @@ -17,9 +29,29 @@ def empty_row_message def headers [ - ["name", { caption: I18n.t("attributes.name") }] + [:name, { caption: I18n.t("attributes.name") }], + [:type, { caption: I18n.t("attributes.type") }], + [:users, { caption: I18n.t(:label_user_plural) }], + [:creator, { caption: I18n.t("js.label_created_by") }], + [:created_at, { caption: OpenIDConnect::Provider.human_attribute_name(:created_at) }] ] end + + def blank_title + I18n.t("openid_connect.providers.label_empty_title") + end + + def blank_description + I18n.t("openid_connect.providers.label_empty_description") + end + + def row_class + ::OpenIDConnect::Providers::RowComponent + end + + def blank_icon + :key + end end end end diff --git a/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb b/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb new file mode 100644 index 000000000000..fd1337d1bd29 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb @@ -0,0 +1,205 @@ +<% ns = OpenIDConnect::Providers %> +<%= component_wrapper do %> + <% if provider.seeded_from_env? %> + <%= + render(Primer::Alpha::Banner.new(mb: 2, scheme: :default, icon: :bell, spacious: true)) do + I18n.t("openid_connect.providers.seeded_from_env") + end + %> + <% end %> + + <%= render(border_box_container) do |component| + case provider.oidc_provider + when 'google' + component.with_row(scheme: :default) do + basic_details_component = if edit_state == :name + ns::Sections::FormComponent.new( + provider, + form_class: ns::NameInputForm, + edit_state:, + next_edit_state: :client_details, + edit_mode:, + heading: nil + ) + else + ns::Sections::ShowComponent.new( + provider, + view_mode:, + target_state: :name, + heading: t("activemodel.attributes.openid_connect/provider.display_name"), + description: t("openid_connect.providers.section_texts.display_name") + ) + end + render(basic_details_component) + end + + component.with_row(scheme: :default) do + if edit_state == :client_details + render(ns::Sections::FormComponent.new( + provider, + form_class: ns::ClientDetailsForm, + edit_state:, + edit_mode:, + heading: nil, + )) + else + render(ns::Sections::ShowComponent.new( + provider, + target_state: :client_details, + view_mode:, + heading: t("openid_connect.providers.label_client_details"), + label: provider.advanced_details_configured? ? t(:label_completed) : t(:label_not_configured), + label_scheme: provider.advanced_details_configured? ? :success : :secondary, + description: t("openid_connect.providers.client_details_description") + )) + end + end + when 'microsoft_entra' + component.with_row(scheme: :default) do + basic_details_component = if edit_state == :name + ns::Sections::FormComponent.new( + provider, + form_class: ns::NameInputAndTenantForm, + edit_state:, + next_edit_state: :client_details, + edit_mode:, + heading: nil + ) + else + ns::Sections::ShowComponent.new( + provider, + view_mode:, + target_state: :name, + heading: t("activemodel.attributes.openid_connect/provider.display_name"), + description: t("openid_connect.providers.section_texts.display_name") + ) + end + render(basic_details_component) + end + + component.with_row(scheme: :default) do + if edit_state == :client_details + render(ns::Sections::FormComponent.new( + provider, + form_class: ns::ClientDetailsForm, + edit_state:, + edit_mode:, + heading: nil + )) + else + render(ns::Sections::ShowComponent.new( + provider, + target_state: :client_details, + view_mode:, + heading: t("openid_connect.providers.label_client_details"), + label: provider.advanced_details_configured? ? t(:label_completed) : t(:label_not_configured), + label_scheme: provider.advanced_details_configured? ? :success : :secondary, + description: t("openid_connect.providers.client_details_description") + )) + end + end + else # custom +# component.with_header(color: :muted) do +# render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t('openid_connect.providers.label_basic_details') } +# end + + component.with_row(scheme: :default) do + basic_details_component = + if edit_state == :name + ns::Sections::FormComponent.new( + provider, + form_class: ns::NameInputForm, + edit_state:, + next_edit_state: :metadata, + edit_mode:, + heading: nil + ) + else + ns::Sections::ShowComponent.new( + provider, + view_mode:, + target_state: :name, + heading: t("activemodel.attributes.openid_connect/provider.display_name"), + description: t("openid_connect.providers.section_texts.display_name") + ) + end + render(basic_details_component) + end + +# component.with_row(scheme: :neutral, color: :muted) do +# render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t('openid_connect.providers.label_automatic_configuration') } +# end + + component.with_row(scheme: :default) do + if edit_state == :metadata + render(ns::Sections::MetadataFormComponent.new( + provider, + edit_mode:, + )) + else + render(ns::Sections::ShowComponent.new( + provider, + target_state: :metadata, + view_mode:, + heading: t("openid_connect.providers.label_metadata"), + description: t("openid_connect.providers.section_texts.metadata"), + label: provider.metadata_url.present? ? t(:label_completed) : t(:label_not_configured), + label_scheme: provider.metadata_url.present? ? :success : :secondary + )) + end + end + +# component.with_row(scheme: :neutral, color: :muted) do +# render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t('openid_connect.providers.label_advanced_configuration') } +# end + + component.with_row(scheme: :default) do + if edit_state == :metadata_details + render(ns::Sections::FormComponent.new( + provider, + form_class: ns::MetadataDetailsForm, + edit_state:, + next_edit_state: :client_details, + edit_mode:, + banner: provider.metadata_configured? ? t("openid_connect.providers.section_texts.configuration_metadata") : nil, + banner_scheme: :default, + heading: nil + )) + else + render(ns::Sections::ShowComponent.new( + provider, + target_state: :metadata_details, + view_mode:, + heading: t("openid_connect.providers.label_configuration_details"), + description: t("openid_connect.providers.section_texts.configuration"), + label: provider.metadata_configured? ? t(:label_completed) : t(:label_not_configured), + label_scheme: provider.metadata_configured? ? :success : :secondary + )) + end + end + + component.with_row(scheme: :default) do + if edit_state == :client_details + render(ns::Sections::FormComponent.new( + provider, + form_class: ns::ClientDetailsForm, + edit_state:, + next_edit_state: :mapping, + edit_mode:, + heading: nil + )) + else + render(ns::Sections::ShowComponent.new( + provider, + target_state: :client_details, + view_mode:, + heading: t("openid_connect.providers.label_client_details"), + description: t("openid_connect.providers.client_details_description"), + label: provider.advanced_details_configured? ? t(:label_completed) : t(:label_not_configured), + label_scheme: provider.advanced_details_configured? ? :success : :secondary + )) + end + end + end + end %> +<% end %> diff --git a/modules/openid_connect/app/components/openid_connect/providers/view_component.rb b/modules/openid_connect/app/components/openid_connect/providers/view_component.rb new file mode 100644 index 000000000000..400203eb270c --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/view_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 OpenIDConnect + module Providers + class ViewComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + options :view_mode, :edit_state, :edit_mode + + alias_method :provider, :model + end + end +end diff --git a/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb new file mode 100644 index 000000000000..e4efc55cdd0b --- /dev/null +++ b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb @@ -0,0 +1,51 @@ +#-- 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 OpenIDConnect + module Providers + class BaseContract < ModelContract + include RequiresAdminGuard + + def self.model + OpenIDConnect::Provider + end + + attribute :display_name + attribute :oidc_provider + validates :oidc_provider, + presence: true, + inclusion: { in: OpenIDConnect::Provider::OIDC_PROVIDERS } + attribute :slug + attribute :options + attribute :limit_self_registration + attribute :metadata_url + validates :metadata_url, + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, + if: -> { model.metadata_url_changed? } + end + end +end diff --git a/modules/openid_connect/app/contracts/openid_connect/providers/create_contract.rb b/modules/openid_connect/app/contracts/openid_connect/providers/create_contract.rb new file mode 100644 index 000000000000..3f6e5232e93d --- /dev/null +++ b/modules/openid_connect/app/contracts/openid_connect/providers/create_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 OpenIDConnect + module Providers + class CreateContract < BaseContract + attribute :type + end + end +end diff --git a/modules/openid_connect/app/contracts/openid_connect/providers/delete_contract.rb b/modules/openid_connect/app/contracts/openid_connect/providers/delete_contract.rb new file mode 100644 index 000000000000..89a44cf5904d --- /dev/null +++ b/modules/openid_connect/app/contracts/openid_connect/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 OpenIDConnect + module Providers + class DeleteContract < ::DeleteContract + delete_permission :admin + end + end +end diff --git a/modules/openid_connect/app/contracts/openid_connect/providers/update_contract.rb b/modules/openid_connect/app/contracts/openid_connect/providers/update_contract.rb new file mode 100644 index 000000000000..4b6ec6a47baa --- /dev/null +++ b/modules/openid_connect/app/contracts/openid_connect/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 OpenIDConnect + module Providers + class UpdateContract < BaseContract + end + end +end 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 ac436d6418fe..f000dd0bbe1e 100644 --- a/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb +++ b/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb @@ -1,44 +1,62 @@ module OpenIDConnect class ProvidersController < ::ApplicationController + include OpTurbo::ComponentStream + layout "admin" menu_item :plugin_openid_connect before_action :require_admin before_action :check_ee before_action :find_provider, only: %i[edit update destroy] + before_action :set_edit_state, only: %i[create edit update] def index; end def new - if openid_connect_providers_available_for_configure.none? - redirect_to action: :index - else - @provider = ::OpenIDConnect::Provider.initialize_with({ use_graph_api: true }) - end + oidc_provider = case params[:oidc_provider] + when "google" + "google" + when "microsoft_entra" + "microsoft_entra" + else + "custom" + end + @provider = OpenIDConnect::Provider.new(oidc_provider:) end def create - @provider = ::OpenIDConnect::Provider.initialize_with(create_params) + create_params = params + .require(:openid_connect_provider) + .permit(:display_name, :oidc_provider) + + call = ::OpenIDConnect::Providers::CreateService + .new(user: User.current) + .call(**create_params) + + @provider = call.result - if @provider.save - flash[:notice] = I18n.t(:notice_successful_create) - redirect_to action: :index + if call.success? + successful_save_response else - render action: :new + failed_save_response(:new) end end def edit; end def update - @provider = ::OpenIDConnect::Provider.initialize_with( - update_params.merge("name" => params[:id]) - ) - if @provider.save - flash[:notice] = I18n.t(:notice_successful_update) - redirect_to action: :index + update_params = params + .require(:openid_connect_provider) + .permit(:display_name, :oidc_provider, :limit_self_registration, *OpenIDConnect::Provider.stored_attributes[:options]) + call = OpenIDConnect::Providers::UpdateService + .new(model: @provider, user: User.current) + .call(update_params) + + if call.success? + successful_save_response else - render action: :edit + @provider = call.result + failed_save_response(edit) end end @@ -61,39 +79,74 @@ def check_ee end end - def create_params - params - .require(:openid_connect_provider) - .permit(:name, :display_name, :identifier, :secret, :limit_self_registration, :tenant, :use_graph_api) - end - - def update_params - params - .require(:openid_connect_provider) - .permit(:display_name, :identifier, :secret, :limit_self_registration, :tenant, :use_graph_api) - end - def find_provider - @provider = providers.find { |provider| provider.id.to_s == params[:id].to_s } + @provider = providers.where(id: params[:id]).first if @provider.nil? render_404 end end def providers - @providers ||= OpenProject::OpenIDConnect.providers + @providers ||= ::OpenIDConnect::Provider.where(available: true) end helper_method :providers - def openid_connect_providers_available_for_configure - Provider::ALLOWED_TYPES.dup - providers.map(&:name) - end - helper_method :openid_connect_providers_available_for_configure - def default_breadcrumb; end def show_local_breadcrumb false end + + def successful_save_response + respond_to do |format| + format.turbo_stream do + update_via_turbo_stream( + component: OpenIDConnect::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 + flash[:notice] = I18n.t(:notice_successful_update) unless @edit_mode + if @edit_mode && @next_edit_state + redirect_to edit_openid_connect_provider_path(@provider, + anchor: "openid-connect-providers-edit-form", + edit_mode: true, + edit_state: @next_edit_state) + else + redirect_to openid_connect_provider_path(@provider) + end + end + end + end + + def failed_save_response(action_to_render) + respond_to do |format| + format.turbo_stream do + update_via_turbo_stream( + component: OpenIDConnect::Providers::ViewComponent.new( + @provider, + edit_mode: @edit_mode, + edit_state: @edit_state, + view_mode: :show + ) + ) + render turbo_stream: turbo_streams + end + format.html do + render action: action_to_render + end + 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/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index df19926d187e..9d5f5c4c8678 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -1,131 +1,84 @@ module OpenIDConnect - class Provider - ALLOWED_TYPES = ["azure", "google"].freeze + class Provider < AuthProvider + OIDC_PROVIDERS = ["google", "microsoft_entra", "custom"].freeze + DISCOVERABLE_ATTRIBUTES_ALL = %i[authorization_endpoint + userinfo_endpoint + token_endpoint + end_session_endpoint + jwks_uri + issuer].freeze + DISCOVERABLE_ATTRIBUTES_OPTIONAL = %i[end_session_endpoint].freeze + DISCOVERABLE_ATTRIBUTES_MANDATORY = DISCOVERABLE_ATTRIBUTES_ALL - %i[end_session_endpoint] - class NewProvider < OpenStruct - def to_h - @table.compact - end + store_attribute :options, :oidc_provider, :string + store_attribute :options, :metadata_url, :string + DISCOVERABLE_ATTRIBUTES_ALL.each do |attribute| + store_attribute :options, attribute, :string end + store_attribute :options, :client_id, :string + store_attribute :options, :client_secret, :string + store_attribute :options, :tenant, :string - extend ActiveModel::Naming - include ActiveModel::Conversion - extend ActiveModel::Translation - attr_reader :errors, :omniauth_provider - - attr_accessor :display_name - - delegate :name, to: :omniauth_provider, allow_nil: true - delegate :identifier, to: :omniauth_provider, allow_nil: true - delegate :secret, to: :omniauth_provider, allow_nil: true - delegate :scope, to: :omniauth_provider, allow_nil: true - delegate :to_h, to: :omniauth_provider, allow_nil: false + def self.slug_fragment = "oidc" - delegate :tenant, to: :omniauth_provider, allow_nil: false - delegate :configuration, to: :omniauth_provider, allow_nil: true - delegate :use_graph_api, to: :omniauth_provider, allow_nil: false - - def initialize(omniauth_provider) - @omniauth_provider = omniauth_provider - @errors = ActiveModel::Errors.new(self) - @display_name = omniauth_provider.to_h[:display_name] + def seeded_from_env? + (Setting.seed_openid_connect_provider || {}).key?(slug) end - def self.initialize_with(params) - normalized = normalized_params(params) - - # We want all providers to be limited by the self registration setting by default - normalized.reverse_merge!(limit_self_registration: true) - - new(NewProvider.new(normalized)) + def basic_details_configured? + display_name.present? && (oidc_provider == "microsoft_entra" ? tenant.present? : true) end - def self.normalized_params(params) - transformed = %i[limit_self_registration use_graph_api].filter_map do |key| - if params.key?(key) - value = params[key] - [key, ActiveRecord::Type::Boolean.new.deserialize(value)] - end - end - - params.merge(transformed.to_h) + def advanced_details_configured? + client_id.present? && client_secret.present? end - def new_record? - !persisted? - end - - def persisted? - omniauth_provider.is_a?(OmniAuth::OpenIDConnect::Provider) + def metadata_configured? + DISCOVERABLE_ATTRIBUTES_MANDATORY.all? do |mandatory_attribute| + public_send(mandatory_attribute).present? + end end - def limit_self_registration - (configuration || {}).fetch(:limit_self_registration, true) + def configured? + basic_details_configured? && advanced_details_configured? && metadata_configured? end - alias_method :limit_self_registration?, :limit_self_registration - def to_h - return {} if omniauth_provider.nil? - - omniauth_provider.to_h - end - - def id - return nil unless persisted? - - name - end - - def valid? - @errors.add(:name, :invalid) unless type_allowed?(name) - @errors.add(:identifier, :blank) if identifier.blank? - @errors.add(:secret, :blank) if secret.blank? - @errors.none? - end - - ## - # Checks if the provider with the given name is of an allowed type. - # - # Types can be followed by a period and arbitrary names to add several - # providers of the same type. E.g. 'azure', 'azure.dep1', 'azure.dep2'. - def type_allowed?(name) - ALLOWED_TYPES.any? { |allowed| name =~ /\A#{allowed}(\..+)?\Z/ } - end - - def save - return false unless valid? - - Setting.plugin_openproject_openid_connect = setting_with_provider - - true - end - - def destroy - Setting.plugin_openproject_openid_connect = setting_without_provider - - true - end - - def setting_with_provider - setting.deep_merge "providers" => { name => to_h.stringify_keys } - end - - def setting_without_provider - setting.tap do |s| - s["providers"].delete name + h = { + name: slug, + icon:, + display_name:, + userinfo_endpoint:, + authorization_endpoint:, + jwks_uri:, + host: URI(issuer).host, + issuer:, + identifier: client_id, + secret: client_secret, + token_endpoint:, + limit_self_registration:, + end_session_endpoint: + }.to_h + + if oidc_provider == "google" + h.merge!({ + client_auth_method: :not_basic, + send_nonce: false, # use state instead of nonce + state: lambda { SecureRandom.hex(42) } + }) end + h end - def setting - Hash(Setting.plugin_openproject_openid_connect).tap do |h| - h["providers"] ||= Hash.new + def icon + case oidc_provider + when "google" + "openid_connect/auth_provider-google.png" + when "microsoft_entra" + "openid_connect/auth_provider-azure.png" + else + "openid_connect/auth_provider-custom.png" end end - - # https://api.rubyonrails.org/classes/ActiveModel/Errors.html - def read_attribute_for_validation(attr) - send(attr) - end end end diff --git a/modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb b/modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb new file mode 100644 index 000000000000..207ba426c74b --- /dev/null +++ b/modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb @@ -0,0 +1,52 @@ +#-- 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 OpenIDConnect + class ProviderSeeder < Seeder + def seed_data! + Setting.seed_openid_connect_provider.each do |name, configuration| + print_status " ↳ Creating or Updating OpenID provider #{name}" do + call = ::OpenIDConnect::SyncService.new(name, configuration).call + + if call.success + print_status " - #{call.message}" + else + raise call.message + end + end + end + end + + def applicable? + Setting.seed_openid_connect_provider.present? + end + end + end +end diff --git a/modules/openid_connect/app/services/openid_connect/providers/create_service.rb b/modules/openid_connect/app/services/openid_connect/providers/create_service.rb new file mode 100644 index 000000000000..734fd5378978 --- /dev/null +++ b/modules/openid_connect/app/services/openid_connect/providers/create_service.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 OpenIDConnect + module Providers + class CreateService < BaseServices::Create + end + end +end diff --git a/modules/openid_connect/app/services/openid_connect/providers/delete_service.rb b/modules/openid_connect/app/services/openid_connect/providers/delete_service.rb new file mode 100644 index 000000000000..11416b30fa3b --- /dev/null +++ b/modules/openid_connect/app/services/openid_connect/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 OpenIDConnect + module Providers + class DeleteService < BaseServices::Delete + end + end +end diff --git a/modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb b/modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb new file mode 100644 index 000000000000..92e90fe00200 --- /dev/null +++ b/modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb @@ -0,0 +1,42 @@ +#-- 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 OpenIDConnect + module Providers + class SetAttributesService < BaseServices::SetAttributes + private + + def set_default_attributes(*) + model.change_by_system do + model.creator ||= user + model.slug ||= "#{model.class.slug_fragment}-#{model.display_name.to_url}" if model.display_name + end + end + end + end +end diff --git a/modules/openid_connect/app/services/openid_connect/providers/update_service.rb b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb new file mode 100644 index 000000000000..2ca3dacb9320 --- /dev/null +++ b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb @@ -0,0 +1,88 @@ +#-- 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 OpenIDConnect + module Providers + class UpdateService < BaseServices::Update + class AttributesContract < Dry::Validation::Contract + params do + OpenIDConnect::Provider::DISCOVERABLE_ATTRIBUTES_MANDATORY.each do |attribute| + required(attribute).filled(:string) + end + OpenIDConnect::Provider::DISCOVERABLE_ATTRIBUTES_OPTIONAL.each do |attribute| + optional(attribute).filled(:string) + end + end + end + + def after_validate(_params, call) + model = call.result + metadata_url = case model.oidc_provider + when "google" + "https://accounts.google.com/.well-known/openid-configuration" + when "microsoft_entra" + "https://login.microsoftonline.com/#{model.tenant || 'common'}/v2.0/.well-known/openid-configuration" + else + model.metadata_url + end + return call if metadata_url.blank? + + case (response = OpenProject.httpx.get(metadata_url)) + in {status: 200..299} + json = begin + response.json + rescue HTTPX::Error + call.errors.add(:metadata_url, :response_is_json) + call.success = false + end + result = AttributesContract.new.call(json) + if result.errors.empty? + model.assign_attributes(result.to_h) + # Microsoft responds with + # "https://login.microsoftonline.com/{tenantid}/v2.0" in issuer field for whatever reason... + if model.oidc_provider == "microsoft_entra" + model.issuer = "https://login.microsoftonline.com/#{model.tenant}/v2.0" + end + else + call.errors.add(:metadata_url, + :response_misses_required_attributes, + missing_attributes: result.errors.to_h.keys.join(", ")) + call.success = false + end + in {status: 300..} + call.errors.add(:metadata_url, :response_is_not_successful, status: response.status) + call.success = false + in {error: error} + raise error + end + + call + end + end + end +end diff --git a/modules/openid_connect/app/services/openid_connect/sync_service.rb b/modules/openid_connect/app/services/openid_connect/sync_service.rb new file mode 100644 index 000000000000..368bdbc5ef0b --- /dev/null +++ b/modules/openid_connect/app/services/openid_connect/sync_service.rb @@ -0,0 +1,68 @@ +#-- 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 OpenIDConnect + class SyncService + attr_reader :name, :configuration + + def initialize(name, configuration) + @name = name + @provider_attributes = + { + "slug" => name, + "oidc_provider" => "custom", + "display_name" => configuration["display_name"], + "client_id" => configuration["identifier"], + "client_secret" => configuration["secret"], + "issuer" => configuration["issuer"], + "authorization_endpoint" => configuration["authorization_endpoint"], + "token_endpoint" => configuration["token_endpoint"], + "userinfo_endpoint" => configuration["userinfo_endpoint"], + "end_session_endpoint" => configuration["end_session_endpoint"], + "jwks_uri" => configuration["jwks_uri"] + } + end + + def call + provider = ::OpenIDConnect::Provider.find_by(slug: name) + if provider + ::OpenIDConnect::Providers::UpdateService + .new(model: provider, user: User.system) + .call(@provider_attributes) + .on_success { |call| call.message = "Successfully updated OpenID provider #{name}." } + .on_failure { |call| call.message = "Failed to update OpenID provider: #{call.message}" } + else + ::OpenIDConnect::Providers::CreateService + .new(user: User.system) + .call(@provider_attributes) + .on_success { |call| call.message = "Successfully created OpenID provider #{name}." } + .on_failure { |call| call.message = "Failed to create OpenID provider: #{call.message}" } + end + end + end +end diff --git a/modules/openid_connect/app/views/openid_connect/providers/_azure_form.html.erb b/modules/openid_connect/app/views/openid_connect/providers/_azure_form.html.erb deleted file mode 100644 index 1c948da0dd0f..000000000000 --- a/modules/openid_connect/app/views/openid_connect/providers/_azure_form.html.erb +++ /dev/null @@ -1,21 +0,0 @@ -<% if (@provider.new_record? && !providers.map(&:name).include?('azure')) || @provider.name == 'azure' %> - <%= content_tag :fieldset, - class: 'form--fieldset', - data: { - 'admin--openid-connect-providers-target': 'azureForm', - }, - hidden: @provider.name.present? && @provider.name != 'azure' do %> -
<%= I18n.t('openid_connect.setting_instructions.azure_deprecation_warning') %>
-- <%= styled_button_tag t(:button_save), class: '-primary -with-icon icon-checkmark' %> - <%= link_to t(:button_cancel), { action: :index }, class: 'button -with-icon icon-cancel' %> -
-<% end %> +<%= render(OpenIDConnect::Providers::ViewComponent.new(@provider, + view_mode: :edit, + edit_mode: @edit_mode, + edit_state: @edit_state)) %> diff --git a/modules/openid_connect/app/views/openid_connect/providers/index.html.erb b/modules/openid_connect/app/views/openid_connect/providers/index.html.erb index 4578dc4a5de1..30c030c5613e 100644 --- a/modules/openid_connect/app/views/openid_connect/providers/index.html.erb +++ b/modules/openid_connect/app/views/openid_connect/providers/index.html.erb @@ -11,15 +11,28 @@ <%= render(Primer::OpenProject::SubHeader.new) do |subheader| - subheader.with_action_button(scheme: :primary, - aria: { label: I18n.t("openid_connect.providers.label_add_new") }, - title: I18n.t("openid_connect.providers.label_add_new"), - tag: :a, - href: new_openid_connect_provider_path) do |button| - button.with_leading_visual_icon(icon: :plus) - t("openid_connect.providers.singular") + subheader.with_action_component do + render(Primer::Alpha::ActionMenu.new( + anchor_align: :end) + ) do |menu| + menu.with_show_button( + scheme: :primary, + aria: { label: I18n.t("openid_connect.providers.label_add_new") }, + ) do |button| + button.with_leading_visual_icon(icon: :plus) + button.with_trailing_action_icon(icon: :"triangle-down") + I18n.t("openid_connect.providers.singular") + end + + OpenIDConnect::Provider::OIDC_PROVIDERS.each do |provider_type| + menu.with_item( + label: I18n.t("openid_connect.providers.#{provider_type}.name"), + href: url_helpers.new_openid_connect_provider_path(oidc_provider: provider_type) + ) + end + end end - end if openid_connect_providers_available_for_configure.any? + end %> <%= render ::OpenIDConnect::Providers::TableComponent.new(rows: providers) %> diff --git a/modules/openid_connect/app/views/openid_connect/providers/new.html.erb b/modules/openid_connect/app/views/openid_connect/providers/new.html.erb index a82a34b5f4e1..e88aa00be8f3 100644 --- a/modules/openid_connect/app/views/openid_connect/providers/new.html.erb +++ b/modules/openid_connect/app/views/openid_connect/providers/new.html.erb @@ -10,16 +10,4 @@ end %> -<%= error_messages_for @provider %> - -<% content_controller 'admin--openid-connect-providers', - dynamic: true %> - -<%= labelled_tabular_form_for @provider, - html: { class: 'form', autocomplete: 'off' } do |f| %> - <%= render partial: "form", locals: { f: f } %> -- <%= styled_button_tag t(:button_create), class: '-primary -with-icon icon-checkmark' %> - <%= link_to t(:button_cancel), { action: :index }, class: 'button -with-icon icon-cancel' %> -
-<% end %> +<%= render(OpenIDConnect::Providers::ViewComponent.new(@provider, edit_mode: true, edit_state: :name)) %> diff --git a/modules/openid_connect/config/locales/en.yml b/modules/openid_connect/config/locales/en.yml index affacbce8a4f..7bede7ed3395 100644 --- a/modules/openid_connect/config/locales/en.yml +++ b/modules/openid_connect/config/locales/en.yml @@ -10,25 +10,76 @@ en: openid_connect/provider: name: Name display_name: Display name - identifier: Identifier + client_id: Client ID + client_secret: Client secret secret: Secret scope: Scope limit_self_registration: Limit self registration + authorization_endpoint: Authorization endpoint + userinfo_endpoint: User information endpoint + token_endpoint: Token endpoint + end_session_endpoint: End session endpoint + jwks_uri: JWKS URI + issuer: Issuer + limit_self_registration: Limit self-registration + tenant: Tenant + metadata_url: Metadata URL + activerecord: + errors: + models: + openid_connect/provider: + attributes: + metadata_url: + format: "Discovery endpoint URL %{message}" + response_is_not_successful: " responds with %{status}." + response_is_not_json: " does not return JSON body." + response_misses_required_attributes: " does not return required attributes. Missing attributes are: %{missing_attributes}." + openid_connect: menu_title: OpenID providers + instructions: + endpoint_url: The endpoint URL given to you by the OpenID Connect provider + metadata_none: I don't have this information + metadata_url: I have a discovery endpoint URL + client_id: This is the client ID given to you by your OpenID Connect provider + client_secret: This is the client secret given to you by your OpenID Connect provider + limit_self_registration: If enabled, users can only register using this provider if configuration on the prvoder's end allows it. + display_name: Then name of the provider. This will be displayed as the login button and in the list of providers. + tenant: Please replace the default tenant with your own if applicable. See this. + settings: + metadata_none: I don't have this information + metadata_url: I have a discovery endpoint URL + endpoint_url: Endpoint URL providers: + seeded_from_env: "This provider was seeded from the environment configuration. It cannot be edited." + google: + name: Google + microsoft_entra: + name: Microsoft Entra + custom: + name: Custom label_add_new: Add a new OpenID provider label_edit: Edit OpenID provider %{name} + label_empty_title: No OIDC providers configured yet. + label_empty_description: Add a provider to see them here. + label_basic_details: Basic details + label_metadata: OpenID Connect Discovery Endpoint + label_automatic_configuration: Automatic configuration + label_advanced_configuration: Advanced configuration + label_configuration_details: Metadata + label_client_details: Client details + client_details_description: Configuration details of OpenProject as an OIDC client no_results_table: No providers have been defined yet. plural: OpenID providers singular: OpenID provider + section_texts: + metadata: Pre-fill configuration using an OpenID Connect discovery endpoint URL + metadata_form_banner: Editing the discovery endpoint may override existing pre-filled metadata values. + metadata_form_title: OpenID Connect Discovery endpoint + metadata_form_description: If your identity provider has a discovery endpoint URL. Use it below to pre-fill configuration. + configuration_metadata: The information has been pre-filled using the supplied discovery endpoint. In most cases, they do not require editing. + configuration: Configuration details of the OpenID Connect provider + display_name: The display name visible to users. setting_instructions: - azure_deprecation_warning: > - The configured Azure app points to a deprecated API from Azure. Please create a new Azure app to ensure the functionality in future. - azure_graph_api: > - Use the graph.microsoft.com userinfo endpoint to request userdata. This should be the default unless you have an older azure application. - azure_tenant_html: > - Set the tenant of your Azure endpoint. This will control who gets access to the OpenProject instance. - For more information, please see our user guide on Azure OpenID connect. limit_self_registration: > If enabled users can only register using this provider if the self registration setting allows for it. diff --git a/modules/openid_connect/config/routes.rb b/modules/openid_connect/config/routes.rb index d10644fbc8fe..f799801c0a06 100644 --- a/modules/openid_connect/config/routes.rb +++ b/modules/openid_connect/config/routes.rb @@ -3,7 +3,7 @@ scope :admin do namespace :openid_connect do - resources :providers, only: %i[index new create edit update destroy] + resources :providers, except: %i[show] end end end diff --git a/modules/openid_connect/lib/open_project/openid_connect.rb b/modules/openid_connect/lib/open_project/openid_connect.rb index c9331a39ee5d..d82e29f7e2d5 100644 --- a/modules/openid_connect/lib/open_project/openid_connect.rb +++ b/modules/openid_connect/lib/open_project/openid_connect.rb @@ -4,28 +4,26 @@ module OpenProject module OpenIDConnect - CONFIG_KEY = "openid_connect".freeze + CONFIG_KEY = :seed_openid_connect_provider + CONFIG_OPTIONS = { + description: "Provide a OpenIDConnect provider and sync its settings through ENV", + env_alias: "OPENPROJECT_OPENID__CONNECT", + default: {}, + writable: false, + format: :hash + }.freeze def providers # update base redirect URI in case settings changed ::OmniAuth::OpenIDConnect::Providers.configure( base_redirect_uri: "#{Setting.protocol}://#{Setting.host_name}#{OpenProject::Configuration['rails_relative_url_root']}" ) - ::OmniAuth::OpenIDConnect::Providers.load(configuration).map do |omniauth_provider| - ::OpenIDConnect::Provider.new(omniauth_provider) + providers = ::OpenIDConnect::Provider.where(available: true).select(&:configured?) + configuration = providers.each_with_object({}) do |provider, hash| + hash[provider.slug] = provider.to_h end + ::OmniAuth::OpenIDConnect::Providers.load(configuration) end module_function :providers - - def configuration - from_settings = if Setting.plugin_openproject_openid_connect.is_a? Hash - Hash(Setting.plugin_openproject_openid_connect["providers"]) - else - {} - end - # Settings override configuration.yml - Hash(OpenProject::Configuration[CONFIG_KEY]).deep_merge(from_settings) - end - module_function :configuration end 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 345651d2967e..a9914ec6c6ac 100644 --- a/modules/openid_connect/lib/open_project/openid_connect/engine.rb +++ b/modules/openid_connect/lib/open_project/openid_connect/engine.rb @@ -22,7 +22,7 @@ class Engine < ::Rails::Engine assets %w( openid_connect/auth_provider-azure.png openid_connect/auth_provider-google.png - openid_connect/auth_provider-heroku.png + openid_connect/auth_provider-custom.png ) class_inflection_override("openid_connect" => "OpenIDConnect") @@ -62,7 +62,8 @@ class Engine < ::Rails::Engine initializer "openid_connect.configure" do ::Settings::Definition.add( - OpenProject::OpenIDConnect::CONFIG_KEY, default: {}, writable: false + OpenProject::OpenIDConnect::CONFIG_KEY, + **OpenProject::OpenIDConnect::CONFIG_OPTIONS ) end @@ -70,7 +71,9 @@ class Engine < ::Rails::Engine # If response_mode 'form_post' is chosen, # the IP sends a POST to the callback. Only if # the sameSite flag is not set on the session cookie, is the cookie send along with the request. - if OpenProject::Configuration["openid_connect"]&.any? { |_, v| v["response_mode"]&.to_s == "form_post" } + if OpenProject::Configuration[OpenProject::OpenIDConnect::CONFIG_KEY]&.any? do |_, v| + v["response_mode"]&.to_s == "form_post" + end SecureHeaders::Configuration.default.cookies[:samesite][:lax] = false # Need to reload the secure_headers config to # avoid having set defaults (e.g. https) when changing the cookie values diff --git a/modules/openid_connect/spec/controllers/providers_controller_spec.rb b/modules/openid_connect/spec/controllers/providers_controller_spec.rb deleted file mode 100644 index 493b4d50d543..000000000000 --- a/modules/openid_connect/spec/controllers/providers_controller_spec.rb +++ /dev/null @@ -1,270 +0,0 @@ -#-- 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 OpenIDConnect::ProvidersController do - let(:user) { build_stubbed(:admin) } - - let(:valid_params) do - { - name: "azure", - identifier: "IDENTIFIER", - secret: "SECRET" - } - end - - before do - login_as user - end - - context "without an EE token", with_ee: false do - it "renders upsale" do - get :index - expect(response).to have_http_status(:ok) - expect(response).to render_template "openid_connect/providers/upsale" - end - end - - context "with an EE token", with_ee: %i[sso_auth_providers] do - before do - login_as user - end - - context "when not admin" do - let(:user) { build_stubbed(:user) } - - it "renders 403" do - get :index - expect(response).to have_http_status(:forbidden) - end - end - - context "when not logged in" do - let(:user) { User.anonymous } - - it "renders 403" do - get :index - expect(response.status).to redirect_to(signin_url(back_url: openid_connect_providers_url)) - end - end - - describe "#index" do - it "renders the index page" do - get :index - expect(response).to be_successful - expect(response).to render_template "index" - end - end - - describe "#new" do - it "renders the new page" do - get :new - expect(response).to be_successful - expect(assigns[:provider]).to be_new_record - expect(response).to render_template "new" - end - - it "redirects to the index page if no provider available", with_settings: { - plugin_openproject_openid_connect: { - "providers" => OpenIDConnect::Provider::ALLOWED_TYPES.inject({}) do |accu, name| - accu.merge(name => { "identifier" => "IDENTIFIER", "secret" => "SECRET" }) - end - } - } do - get :new - expect(response).to be_redirect - end - end - - describe "#create" do - context "with valid params" do - let(:params) { { openid_connect_provider: valid_params } } - - before do - post :create, params: - end - - it "is successful" do - expect(flash[:notice]).to eq(I18n.t(:notice_successful_create)) - expect(Setting.plugin_openproject_openid_connect["providers"]).to have_key("azure") - expect(response).to be_redirect - end - - context "with limit_self_registration checked" do - let(:params) do - { openid_connect_provider: valid_params.merge(limit_self_registration: 1) } - end - - it "sets the setting" do - expect(OpenProject::Plugins::AuthPlugin) - .to be_limit_self_registration provider: valid_params[:name] - end - end - - context "with limit_self_registration unchecked" do - let(:params) do - { openid_connect_provider: valid_params.merge(limit_self_registration: 0) } - end - - it "does not set the setting" do - expect(OpenProject::Plugins::AuthPlugin) - .not_to be_limit_self_registration provider: valid_params[:name] - end - end - end - - it "renders an error if invalid params" do - post :create, params: { openid_connect_provider: valid_params.merge(identifier: "") } - expect(response).to render_template "new" - end - end - - describe "#edit" do - context "when found", with_settings: { - plugin_openproject_openid_connect: { - "providers" => { "azure" => { "identifier" => "IDENTIFIER", "secret" => "SECRET" } } - } - } do - it "renders the edit page" do - get :edit, params: { id: "azure" } - expect(response).to be_successful - expect(assigns[:provider]).to be_present - expect(response).to render_template "edit" - end - - context( - "with limit_self_registration set", - with_settings: { - plugin_openproject_openid_connect: { - "providers" => { - "azure" => { - "identifier" => "IDENTIFIER", - "secret" => "SECRET", - "limit_self_registration" => true - } - } - } - } - ) do - before do - get :edit, params: { id: "azure" } - end - - it "shows limit_self_registration as checked" do - expect(assigns[:provider]).to be_limit_self_registration - end - end - - context "with limit_self_registration not set" do - before do - get :edit, params: { id: "azure" } - end - - it "shows limit_self_registration as checked" do - expect(assigns[:provider]).to be_limit_self_registration - end - end - end - - context "when not found" do - it "renders 404" do - get :edit, params: { id: "doesnoexist" } - expect(response).not_to be_successful - expect(response).to have_http_status(:not_found) - end - end - end - - describe "#update" do - context "when found" do - before do - Setting.plugin_openproject_openid_connect = { - "providers" => { "azure" => { "identifier" => "IDENTIFIER", "secret" => "SECRET" } } - } - end - - it "successfully updates the provider configuration" do - put :update, params: { id: "azure", openid_connect_provider: valid_params.merge(secret: "NEWSECRET") } - expect(response).to be_redirect - expect(flash[:notice]).to be_present - provider = OpenProject::OpenIDConnect.providers.find { |item| item.name == "azure" } - expect(provider.secret).to eq("NEWSECRET") - end - - context "with limit_self_registration checked" do - let(:params) do - { id: "azure", openid_connect_provider: valid_params.merge(limit_self_registration: 1) } - end - - it "sets the setting" do - put(:update, params:) - - expect(OpenProject::Plugins::AuthPlugin) - .to be_limit_self_registration provider: :azure - provider = OpenProject::OpenIDConnect.providers.find { |item| item.name == "azure" } - expect(provider.limit_self_registration).to be true - end - end - - context "with limit_self_registration unchecked" do - let(:params) do - { id: :azure, openid_connect_provider: valid_params.merge(limit_self_registration: 0) } - end - - it "does not set the setting" do - put(:update, params:) - - expect(OpenProject::Plugins::AuthPlugin) - .not_to be_limit_self_registration provider: valid_params[:name] - - provider = OpenProject::OpenIDConnect.providers.find { |item| item.name == "azure" } - expect(provider.limit_self_registration).to be false - end - end - end - end - - describe "#destroy" do - context "when found" do - before do - Setting.plugin_openproject_openid_connect = { - "providers" => { "azure" => { "identifier" => "IDENTIFIER", "secret" => "SECRET" } } - } - end - - it "removes the provider" do - delete :destroy, params: { id: "azure" } - expect(response).to be_redirect - expect(flash[:notice]).to be_present - expect(OpenProject::OpenIDConnect.providers).to be_empty - end - end - end - end -end diff --git a/modules/openid_connect/spec/factories/oidc_provider_factory.rb b/modules/openid_connect/spec/factories/oidc_provider_factory.rb new file mode 100644 index 000000000000..bc5502c8dbf4 --- /dev/null +++ b/modules/openid_connect/spec/factories/oidc_provider_factory.rb @@ -0,0 +1,41 @@ +FactoryBot.define do + factory :oidc_provider, class: "OpenIDConnect::Provider" do + display_name { "Foobar" } + slug { "oidc-foobar" } + limit_self_registration { true } + creator factory: :user + + options do + { + "issuer" => "https://keycloak.local/realms/master", + "jwks_uri" => "https://keycloak.local/realms/master/protocol/openid-connect/certs", + "client_id" => "https://openproject.local", + "client_secret" => "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", + "metadata_url" => "https://keycloak.local/realms/master/.well-known/openid-configuration", + "oidc_provider" => "custom", + "token_endpoint" => "https://keycloak.local/realms/master/protocol/openid-connect/token", + "userinfo_endpoint" => "https://keycloak.local/realms/master/protocol/openid-connect/userinfo", + "end_session_endpoint" => "https://keycloak.local/realms/master/protocol/openid-connect/logout", + "authorization_endpoint" => "https://keycloak.local/realms/master/protocol/openid-connect/auth" + } + end + end + + factory :oidc_provider_google, class: "OpenIDConnect::Provider" do + display_name { "Google" } + slug { "oidc-google" } + limit_self_registration { true } + creator factory: :user + + options do + { "issuer" => "https://accounts.google.com", + "jwks_uri" => "https://www.googleapis.com/oauth2/v3/certs", + "client_id" => "identifier", + "client_secret" => "secret", + "oidc_provider" => "google", + "token_endpoint" => "https://oauth2.googleapis.com/token", + "userinfo_endpoint" => "https://openidconnect.googleapis.com/v1/userinfo", + "authorization_endpoint" => "https://accounts.google.com/o/oauth2/v2/auth" } + end + end +end diff --git a/modules/openid_connect/spec/models/openid_connect/provider_spec.rb b/modules/openid_connect/spec/models/openid_connect/provider_spec.rb deleted file mode 100644 index f58d071c0fdf..000000000000 --- a/modules/openid_connect/spec/models/openid_connect/provider_spec.rb +++ /dev/null @@ -1,100 +0,0 @@ -#-- 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 OpenIDConnect::Provider do - let(:params) do - {} - end - let(:provider) do - described_class.initialize_with({ name: "azure", identifier: "id", secret: "secret" }.merge(params)) - end - - def auth_plugin - OpenProject::Plugins::AuthPlugin - end - - describe "limit_self_registration" do - before do - # required so that the auth plugin sees any providers (ee feature) - allow(EnterpriseToken).to receive(:show_banners?).and_return false - end - - context "with no limited providers" do - it "shows the provider as limited" do - provider.save - expect(auth_plugin).to be_limit_self_registration provider: provider.name - end - - context "when set to true" do - let(:params) do - { limit_self_registration: true } - end - - it "saving the provider makes it limited" do - provider.save - - expect(auth_plugin).to be_limit_self_registration provider: provider.name - end - end - - context "when set to false" do - let(:params) do - { limit_self_registration: false } - end - - it "saving the provider does nothing" do - provider.save - - expect(auth_plugin).not_to be_limit_self_registration provider: provider.name - end - end - end - - context( - "with a limited provider", - with_settings: { - plugin_openproject_openid_connect: { - "providers" => { - "azure" => { - "name" => "azure", - "identifier" => "id", - "secret" => "secret", - "limit_self_registration" => true - } - } - } - } - ) do - it "shows the provider as limited" do - expect(auth_plugin).to be_limit_self_registration provider: provider.name - end - end - end -end diff --git a/modules/openid_connect/spec/requests/openid_connect_spec.rb b/modules/openid_connect/spec/requests/openid_connect_spec.rb index 808902e6aad0..63af8aa65679 100644 --- a/modules/openid_connect/spec/requests/openid_connect_spec.rb +++ b/modules/openid_connect/spec/requests/openid_connect_spec.rb @@ -36,7 +36,7 @@ RSpec.describe "OpenID Connect", :skip_2fa_stage, # Prevent redirects to 2FA stage type: :rails_request, with_ee: %i[sso_auth_providers] do - let(:host) { OmniAuth::OpenIDConnect::Heroku.new("foo", {}).host } + let(:host) { "keycloak.local" } let(:user_info) do { sub: "87117114115116", @@ -67,20 +67,13 @@ describe "sign-up and login" do before do - allow(Setting).to receive(:plugin_openproject_openid_connect).and_return( - "providers" => { - "heroku" => { - "identifier" => "does not", - "secret" => "matter" - } - } - ) + create(:oidc_provider, slug: "keycloak", limit_self_registration: false) end it "works" do ## # it should redirect to the provider's openid connect authentication endpoint - click_on_signin + click_on_signin("keycloak") expect(response).to have_http_status :found expect(response.location).to match /https:\/\/#{host}.*$/ @@ -88,12 +81,12 @@ params = Rack::Utils.parse_nested_query(response.location.gsub(/^.*\?/, "")) expect(params).to include "client_id" - expect(params["redirect_uri"]).to match /^.*\/auth\/heroku\/callback$/ + expect(params["redirect_uri"]).to match /^.*\/auth\/keycloak\/callback$/ expect(params["scope"]).to include "openid" ## # it should redirect back from the provider to the login page - redirect_from_provider + redirect_from_provider("keycloak") expect(response).to have_http_status :found expect(response.location).to match /\/\?first_time_user=true$/ @@ -109,14 +102,14 @@ user.activate user.save! - click_on_signin + click_on_signin("keycloak") expect(response).to have_http_status :found expect(response.location).to match /https:\/\/#{host}.*$/ ## # it should then login the user upon the redirect back from the provider - redirect_from_provider + redirect_from_provider("keycloak") expect(response).to have_http_status :found expect(response.location).to match /\/my\/page/ @@ -147,6 +140,7 @@ end it "maps to the login" do + skip "Mapping is not supported yet" click_on_signin redirect_from_provider @@ -155,36 +149,4 @@ end end end - - context "provider configuration through the settings" do - before do - allow(Setting).to receive(:plugin_openproject_openid_connect).and_return( - "providers" => { - "google" => { - "identifier" => "does not", - "secret" => "matter" - }, - "azure" => { - "identifier" => "IDENTIFIER", - "secret" => "SECRET" - } - } - ) - end - - it "shows no option unless EE", with_ee: false do - get "/login" - expect(response.body).not_to match /Google/i - expect(response.body).not_to match /Azure/i - end - - it "makes providers that have been configured through settings available without requiring a restart" do - get "/login" - expect(response.body).to match /Google/i - expect(response.body).to match /Azure/i - - expect { click_on_signin("google") }.not_to raise_error - expect(response).to have_http_status :found - end - end end diff --git a/modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb b/modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb new file mode 100644 index 000000000000..96519c500c7b --- /dev/null +++ b/modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb @@ -0,0 +1,135 @@ +# 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::OpenIDConnect::ProviderSeeder, :settings_reset do + let(:seed_data) { Source::SeedData.new({}) } + + subject(:seeder) { described_class.new(seed_data) } + + before do + reset(OpenProject::OpenIDConnect::CONFIG_KEY, **OpenProject::OpenIDConnect::CONFIG_OPTIONS) + end + + context "when not provided" do + it "does nothing" do + expect { seeder.seed! }.not_to change(OpenIDConnect::Provider, :count) + end + end + + context "when providing seed variables", + with_env: { + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_DISPLAY__NAME: "Keycloak", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_HOST: "keycloak.internal", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_IDENTIFIER: "https://openproject.internal", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_SECRET: "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_ISSUER: "https://keycloak.local/realms/master", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_AUTHORIZATION__ENDPOINT: "/realms/master/protocol/openid-connect/auth", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_TOKEN__ENDPOINT: "/realms/master/protocol/openid-connect/token", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_USERINFO__ENDPOINT: "/realms/master/protocol/openid-connect/userinfo", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_END__SESSION__ENDPOINT: "https://keycloak.local/realms/master/protocol/openid-connect/logout", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_JWKS__URI: "https://keycloak.local/realms/master/protocol/openid-connect/certs" + } do + it "uses those variables" do + expect { seeder.seed! }.to change(OpenIDConnect::Provider, :count).by(1) + + provider = OpenIDConnect::Provider.last + expect(provider.slug).to eq "keycloak" + expect(provider.display_name).to eq "Keycloak" + expect(provider.oidc_provider).to eq "custom" + expect(provider.client_id).to eq "https://openproject.internal" + expect(provider.client_secret).to eq "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn" + expect(provider.issuer).to eq "https://keycloak.local/realms/master" + expect(provider.authorization_endpoint).to eq "/realms/master/protocol/openid-connect/auth" + expect(provider.token_endpoint).to eq "/realms/master/protocol/openid-connect/token" + expect(provider.userinfo_endpoint).to eq "/realms/master/protocol/openid-connect/userinfo" + expect(provider.end_session_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/logout" + expect(provider.jwks_uri).to eq "https://keycloak.local/realms/master/protocol/openid-connect/certs" + expect(provider.seeded_from_env?).to be true + end + + context "when provider already exists with that name" do + it "updates the provider" do + provider = OpenIDConnect::Provider.create!(display_name: "Something", slug: "keycloak", creator: User.system) + expect(provider.seeded_from_env?).to be true + + expect { seeder.seed! }.not_to change(OpenIDConnect::Provider, :count) + + provider.reload + + expect(provider.slug).to eq "keycloak" + expect(provider.display_name).to eq "Keycloak" + expect(provider.oidc_provider).to eq "custom" + expect(provider.client_id).to eq "https://openproject.internal" + expect(provider.client_secret).to eq "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn" + expect(provider.issuer).to eq "https://keycloak.local/realms/master" + expect(provider.authorization_endpoint).to eq "/realms/master/protocol/openid-connect/auth" + expect(provider.token_endpoint).to eq "/realms/master/protocol/openid-connect/token" + expect(provider.userinfo_endpoint).to eq "/realms/master/protocol/openid-connect/userinfo" + expect(provider.end_session_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/logout" + expect(provider.jwks_uri).to eq "https://keycloak.local/realms/master/protocol/openid-connect/certs" + expect(provider.seeded_from_env?).to be true + end + end + end + + context "when providing multiple variables", + with_env: { + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_DISPLAY__NAME: "Keycloak", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_HOST: "keycloak.internal", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_IDENTIFIER: "https://openproject.internal", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_SECRET: "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_ISSUER: "https://keycloak.local/realms/master", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_AUTHORIZATION__ENDPOINT: "/realms/master/protocol/openid-connect/auth", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_TOKEN__ENDPOINT: "/realms/master/protocol/openid-connect/token", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_USERINFO__ENDPOINT: "/realms/master/protocol/openid-connect/userinfo", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_END__SESSION__ENDPOINT: "https://keycloak.local/realms/master/protocol/openid-connect/logout", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_JWKS__URI: "https://keycloak.local/realms/master/protocol/openid-connect/certs", + + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_DISPLAY__NAME: "Keycloak 123", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_HOST: "keycloak.internal", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_IDENTIFIER: "https://openproject.internal", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_SECRET: "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_ISSUER: "https://keycloak.local/realms/master", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_AUTHORIZATION__ENDPOINT: "/realms/master/protocol/openid-connect/auth", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_TOKEN__ENDPOINT: "/realms/master/protocol/openid-connect/token", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_USERINFO__ENDPOINT: "/realms/master/protocol/openid-connect/userinfo", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_END__SESSION__ENDPOINT: "https://keycloak.local/realms/master/protocol/openid-connect/logout", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_JWKS__URI: "https://keycloak.local/realms/master/protocol/openid-connect/certs" + } do + it "creates both" do + expect { seeder.seed! }.to change(OpenIDConnect::Provider, :count).by(2) + + providers = OpenIDConnect::Provider.pluck(:slug) + expect(providers).to contain_exactly("keycloak", "keycloak123") + end + end +end diff --git a/spec/requests/api/v3/authentication_spec.rb b/spec/requests/api/v3/authentication_spec.rb index 045177706b6a..30171e45691c 100644 --- a/spec/requests/api/v3/authentication_spec.rb +++ b/spec/requests/api/v3/authentication_spec.rb @@ -366,28 +366,7 @@ def set_basic_auth_header(user, password) end end - describe( - "OIDC", - :webmock, - with_settings: { - plugin_openproject_openid_connect: { - "providers" => { - "keycloak" => { - "display_name" => "Keycloak", - "identifier" => "https://openproject.local", - "secret" => "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", - "host" => "keycloak.local", - "issuer" => "https://keycloak.local/realms/master", - "authorization_endpoint" => "/realms/master/protocol/openid-connect/auth", - "token_endpoint" => "/realms/master/protocol/openid-connect/token", - "userinfo_endpoint" => "/realms/master/protocol/openid-connect/userinfo", - "end_session_endpoint" => "https://keycloak.local/realms/master/protocol/openid-connect/logout", - "jwks_uri" => "https://keycloak.local/realms/master/protocol/openid-connect/certs" - } - } - } - } - ) do + describe("OIDC", :webmock) do let(:rsa_signed_access_token_without_aud) do "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI5N0FteXZvUzhCRkZSZm01ODVHUGdBMTZHMUgyVjIyRWR4eHVBWVV1b0trIn0.eyJleHAiOjE3MjEyODM0MzAsImlhdCI6MTcyMTI4MzM3MCwianRpIjoiYzUyNmI0MzUtOTkxZi00NzRhLWFkMWItYzM3MTQ1NmQxZmQwIiwiaXNzIjoiaHR0cHM6Ly9rZXljbG9hay5sb2NhbC9yZWFsbXMvbWFzdGVyIiwiYXVkIjpbIm1hc3Rlci1yZWFsbSIsImFjY291bnQiXSwic3ViIjoiYjcwZTJmYmYtZWE2OC00MjBjLWE3YTUtMGEyODdjYjY4OWM2IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiaHR0cHM6Ly9vcGVucHJvamVjdC5sb2NhbCIsInNlc3Npb25fc3RhdGUiOiJlYjIzNTI0MC0wYjQ3LTQ4ZmEtOGIzZS1mM2IzMTBkMzUyZTMiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbImh0dHBzOi8vb3BlbnByb2plY3QubG9jYWwiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImNyZWF0ZS1yZWFsbSIsImRlZmF1bHQtcm9sZXMtbWFzdGVyIiwib2ZmbGluZV9hY2Nlc3MiLCJhZG1pbiIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsibWFzdGVyLXJlYWxtIjp7InJvbGVzIjpbInZpZXctcmVhbG0iLCJ2aWV3LWlkZW50aXR5LXByb3ZpZGVycyIsIm1hbmFnZS1pZGVudGl0eS1wcm92aWRlcnMiLCJpbXBlcnNvbmF0aW9uIiwiY3JlYXRlLWNsaWVudCIsIm1hbmFnZS11c2VycyIsInF1ZXJ5LXJlYWxtcyIsInZpZXctYXV0aG9yaXphdGlvbiIsInF1ZXJ5LWNsaWVudHMiLCJxdWVyeS11c2VycyIsIm1hbmFnZS1ldmVudHMiLCJtYW5hZ2UtcmVhbG0iLCJ2aWV3LWV2ZW50cyIsInZpZXctdXNlcnMiLCJ2aWV3LWNsaWVudHMiLCJtYW5hZ2UtYXV0aG9yaXphdGlvbiIsIm1hbmFnZS1jbGllbnRzIiwicXVlcnktZ3JvdXBzIl19LCJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6ImVtYWlsIHByb2ZpbGUiLCJzaWQiOiJlYjIzNTI0MC0wYjQ3LTQ4ZmEtOGIzZS1mM2IzMTBkMzUyZTMiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6ImFkbWluIn0.cLgbN9kygRwthUx0R0FazPfIUeEUVnw4HnDgN-Hsnm9oXVr6MqmfTRKEI-6n62dlnVKsdadF_tWf3jp26d6neLj1zlR-vojwaHm8A08S9m6IeMr9e0CGiYVHjrJtEeTgq6P9cJJfe7uuhSSvlG3ltFPDxaAe14Dz3BjhLO3iaCRkWfAZjKmnW-IMzzzHfGH-7of7qCAlF5ObEax38mf1Q0OmsPA4_5po-FFtw7H7FfDjsr6EXgtdwloDePkk2XIHs2XsIo0YugVHC9GqCWgBA8MBvCirFivqM53paZMnjhpQH-xgTpYGWlw3WNbG2Rny2GoEwIxdYOUO2amDQ_zkrQ" end @@ -400,6 +379,7 @@ def set_basic_auth_header(user, password) let(:keys_request_stub) { nil } before do + create(:oidc_provider, slug: "keycloak") create(:user, identity_url: "keycloak:#{token_sub}") keys_request_stub diff --git a/spec/requests/openid_google_provider_callback_spec.rb b/spec/requests/openid_google_provider_callback_spec.rb index 5f86a8ac23dc..d648991cc819 100644 --- a/spec/requests/openid_google_provider_callback_spec.rb +++ b/spec/requests/openid_google_provider_callback_spec.rb @@ -33,6 +33,7 @@ include Rack::Test::Methods include API::V3::Utilities::PathHelper + let(:provider) { create(:oidc_provider_google, limit_self_registration: false) } let(:auth_hash) do { "state" => "623960f1b4f1020941387659f022497f536ad3c95fa7e53b0f03bdbf36debd59f76320801ea2723df520", "code" => "4/0AVHEtk6HMPLH08Uw8OVoSaAbd2oTi7Z6wOlBsMQ99Yj3qgKhhyKAxUQBvQ2MZuRzvueOgQ", @@ -41,7 +42,7 @@ "prompt" => "none" } end let(:uri) do - uri = URI("/auth/google/callback") + uri = URI("/auth/#{provider.slug}/callback") uri.query = URI.encode_www_form([["code", auth_hash["code"]], ["state", auth_hash["state"]], ["scope", auth_hash["scope"]], @@ -51,14 +52,7 @@ end before do - # enable self registration for Google which is limited by default - expect(OpenProject::Plugins::AuthPlugin) - .to receive(:limit_self_registration?) - .with(provider: "google") - .twice - .and_return false - - stub_request(:post, "https://accounts.google.com/o/oauth2/token").to_return( + stub_request(:post, "https://oauth2.googleapis.com/token").to_return( status: 200, body: { "access_token" => @@ -72,7 +66,7 @@ }.to_json, headers: { "content-type" => "application/json; charset=utf-8" } ) - stub_request(:get, Addressable::Template.new("https://www.googleapis.com/oauth2/v3/userinfo{?alt}")).to_return( + stub_request(:get, "https://openidconnect.googleapis.com/v1/userinfo").to_return( status: 200, body: { "sub" => "107403511037921355307", "name" => "Firstname Lastname", @@ -86,16 +80,14 @@ ) allow_any_instance_of(OmniAuth::Strategies::OpenIDConnect).to receive(:session) { - { "omniauth.state" => auth_hash["state"] } + { + "omniauth.state" => auth_hash["state"] + } } end - it "redirects user without errors", :webmock, with_settings: { - plugin_openproject_openid_connect: { - "providers" => { "google" => { "identifier" => "identifier", "secret" => "secret" } } - } - } do - response = get uri.to_s + it "redirects user without errors", :webmock do + response = get(uri.to_s) expect(response).to have_http_status(:found) expect(response.location).to eq("http://#{Setting.host_name}/two_factor_authentication/request") end diff --git a/spec/services/users/register_user_service_spec.rb b/spec/services/users/register_user_service_spec.rb index 16151dc25534..9d98aa9db5c8 100644 --- a/spec/services/users/register_user_service_spec.rb +++ b/spec/services/users/register_user_service_spec.rb @@ -100,13 +100,9 @@ def with_all_registration_options(except: []) context "with limit_self_registration enabled and self_registration disabled", with_settings: { self_registration: 0, - plugin_openproject_openid_connect: { - providers: { - azure: { identifier: "foo", secret: "bar", limit_self_registration: true } - } - } } do it "fails to activate due to disabled self registration" do + create(:oidc_provider, slug: 'azure') call = instance.call expect(call).not_to be_success expect(call.result).to eq user @@ -117,13 +113,9 @@ def with_all_registration_options(except: []) context "with limit_self_registration enabled and self_registration manual", with_settings: { self_registration: 2, - plugin_openproject_openid_connect: { - providers: { - azure: { identifier: "foo", secret: "bar", limit_self_registration: true } - } - } } do it "registers the user, but does not activate it" do + create(:oidc_provider, slug: 'azure') call = instance.call expect(call).to be_success expect(call.result).to eq user @@ -136,13 +128,10 @@ def with_all_registration_options(except: []) context "with limit_self_registration enabled and self_registration email", with_settings: { self_registration: 1, - plugin_openproject_openid_connect: { - providers: { - azure: { identifier: "foo", secret: "bar", limit_self_registration: true } - } - } } do it "registers the user, but does not activate it" do + create(:oidc_provider, slug: 'azure') + call = instance.call expect(call).to be_success expect(call.result).to eq user @@ -155,13 +144,9 @@ def with_all_registration_options(except: []) context "with limit_self_registration enabled and self_registration automatic", with_settings: { self_registration: 3, - plugin_openproject_openid_connect: { - providers: { - azure: { identifier: "foo", secret: "bar", limit_self_registration: true } - } - } } do it "activates the user" do + create(:oidc_provider, slug: 'azure') call = instance.call expect(call).to be_success expect(call.result).to eq user diff --git a/spec/support/shared/with_settings.rb b/spec/support/shared/with_settings.rb index d4286554d096..6ab6307e4e06 100644 --- a/spec/support/shared/with_settings.rb +++ b/spec/support/shared/with_settings.rb @@ -42,8 +42,8 @@ def aggregate_mocked_settings(example, settings) shared_let(:definitions_before) { Settings::Definition.all.dup } def reset(setting, **definitions) + setting = setting.to_sym definitions = Settings::Definition::DEFINITIONS[setting] if definitions.empty? - Settings::Definition.all.delete(setting) Settings::Definition.add(setting, **definitions) end