From e0c5dd3ef63b8ee10f28484ca54c35436f6768c3 Mon Sep 17 00:00:00 2001 From: Pavel Balashou Date: Fri, 11 Oct 2024 19:19:43 +0200 Subject: [PATCH 01/41] Update OIDC configuration UI. --- .../border_box_table_component.html.erb | 10 +- ...0616_migrate_oidc_settings_to_providers.rb | 71 +++++ .../app/views/hooks/login/_providers.html.erb | 8 +- .../views/hooks/login/_providers_css.html.erb | 16 +- .../controllers/saml/providers_controller.rb | 2 +- .../features/administration/saml_crud_spec.rb | 2 +- .../openid_connect/auth_provider-custom.png | Bin 0 -> 6478 bytes .../openid_connect/auth_provider-heroku.png | Bin 1251 -> 0 bytes .../openid_connect/providers/base_form.rb | 40 +++ .../providers/client_details_form.rb | 53 ++++ .../providers/metadata_details_form.rb | 45 +++ .../providers/metadata_options_form.rb | 58 ++++ .../providers/metadata_url_form.rb | 44 +++ .../providers/name_input_and_tenant_form.rb | 54 ++++ .../providers/name_input_form.rb | 45 +++ .../openid_connect/providers/row_component.rb | 57 ++-- .../sections/form_component.html.erb | 41 +++ .../providers/sections/form_component.rb | 78 +++++ .../sections/metadata_form_component.html.erb | 56 ++++ .../sections/metadata_form_component.rb | 37 +++ .../sections/show_component.html.erb | 45 +++ .../providers/sections/show_component.rb | 50 ++++ .../providers/submit_or_cancel_form.rb | 85 ++++++ .../providers/table_component.rb | 38 ++- .../providers/view_component.html.erb | 205 +++++++++++++ .../providers/view_component.rb | 42 +++ .../openid_connect/providers/base_contract.rb | 51 ++++ .../providers/create_contract.rb | 34 +++ .../providers/delete_contract.rb | 35 +++ .../providers/update_contract.rb | 34 +++ .../openid_connect/providers_controller.rb | 125 +++++--- .../app/models/openid_connect/provider.rb | 169 ++++------- .../openid_connect/provider_seeder.rb | 52 ++++ .../providers/create_service.rb | 34 +++ .../providers/delete_service.rb | 33 +++ .../providers/set_attributes_service.rb | 42 +++ .../providers/update_service.rb | 88 ++++++ .../services/openid_connect/sync_service.rb | 68 +++++ .../providers/_azure_form.html.erb | 21 -- .../openid_connect/providers/_form.html.erb | 43 --- .../openid_connect/providers/edit.html.erb | 39 ++- .../openid_connect/providers/index.html.erb | 29 +- .../openid_connect/providers/new.html.erb | 14 +- modules/openid_connect/config/locales/en.yml | 67 ++++- modules/openid_connect/config/routes.rb | 2 +- .../lib/open_project/openid_connect.rb | 26 +- .../lib/open_project/openid_connect/engine.rb | 9 +- .../controllers/providers_controller_spec.rb | 270 ------------------ .../spec/factories/oidc_provider_factory.rb | 41 +++ .../models/openid_connect/provider_spec.rb | 100 ------- .../spec/requests/openid_connect_spec.rb | 54 +--- .../openid_connect/provider_seeder_spec.rb | 135 +++++++++ spec/requests/api/v3/authentication_spec.rb | 24 +- .../openid_google_provider_callback_spec.rb | 26 +- .../users/register_user_service_spec.rb | 25 +- spec/support/shared/with_settings.rb | 2 +- 56 files changed, 2085 insertions(+), 789 deletions(-) create mode 100644 db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb create mode 100644 modules/openid_connect/app/assets/images/openid_connect/auth_provider-custom.png delete mode 100644 modules/openid_connect/app/assets/images/openid_connect/auth_provider-heroku.png create mode 100644 modules/openid_connect/app/components/openid_connect/providers/base_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/client_details_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/metadata_details_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/metadata_options_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/metadata_url_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/name_input_and_tenant_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/name_input_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/sections/form_component.html.erb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.html.erb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/sections/show_component.html.erb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/sections/show_component.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/submit_or_cancel_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/view_component.rb create mode 100644 modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb create mode 100644 modules/openid_connect/app/contracts/openid_connect/providers/create_contract.rb create mode 100644 modules/openid_connect/app/contracts/openid_connect/providers/delete_contract.rb create mode 100644 modules/openid_connect/app/contracts/openid_connect/providers/update_contract.rb create mode 100644 modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb create mode 100644 modules/openid_connect/app/services/openid_connect/providers/create_service.rb create mode 100644 modules/openid_connect/app/services/openid_connect/providers/delete_service.rb create mode 100644 modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb create mode 100644 modules/openid_connect/app/services/openid_connect/providers/update_service.rb create mode 100644 modules/openid_connect/app/services/openid_connect/sync_service.rb delete mode 100644 modules/openid_connect/app/views/openid_connect/providers/_azure_form.html.erb delete mode 100644 modules/openid_connect/app/views/openid_connect/providers/_form.html.erb delete mode 100644 modules/openid_connect/spec/controllers/providers_controller_spec.rb create mode 100644 modules/openid_connect/spec/factories/oidc_provider_factory.rb delete mode 100644 modules/openid_connect/spec/models/openid_connect/provider_spec.rb create mode 100644 modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb 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 0000000000000000000000000000000000000000..a3f8d7d23978a56c7600ab28229e26926d90b415 GIT binary patch literal 6478 zcmeHL`9G9@wEsLaV<|}}YnBws5~31evLq@*_O*nui)@K`k|OaH*|L9C$d-LyOUPcy z&e+F3)@)k`YlpLUaCCBZadms_ z?(xLa%iG8IsoyjI06hFW@I}zeSHU5#L*Imj{}b^xGU{D)Ol(~I`-H@#JF$L*v(PP2ZbaT7R^)cXW1j_x$|zyZ29D z|G?nT@W|*GVSHk8YI;&U~p^adCTBg{64ZkC?O}Z`VaUAa2B05>qx7x?C&k2@z7$?%fi(FEk{}b*bT* zwDGihqScq4(}O#CU%3QOKrg2XQNr?U&ohJg1bsVhX24NR4*z=sfSj{uM?z0`Q*&>2xT#{g~`*eDce`6FbkeC=IZ1+u!t7FQB3WdR0S*b7)S`In z=&*^M+I}YDwZDKf6Uq4q;NWCNUUH}sTezM8V2?PW}_vWXD28^G0t0>`m`}Qy9MLh)w_3=B-35@OYld~4dkEy9+pK;tS)Y?KwRmA@4$5w_EW*Mt@|kA<@D=_uhNBPy+}Ayf^eO&O?y96Zd&-c(oK5!{9X>xJaw=CYYJKL zl#>xf$nQ0P)z{k1u49c{+JDzi>JPS)aGufhprSORX2T=ZFJSVvU3IH@K=*O#Sro|6 z^NfNCtMVG;O}Ed~t#Ss$M!~enJ#PIp`sp0rE4Jx_qPRAa*jt0>TQ=)CB<)#Y!}j4# zd1M8Ay+X%6t37|)pz#M~{G!I9r87G-2w7^({9%sclDr_d%O@{vvwJ%q*>^ujPe=8B zR_=B&)Z6>lkEPCgZ)C&=1vt%diE}yAjJm_CZDLP%{nbYVQ9y$yi_+vnVU1eQKFM0M z+`r&00NxH1wV1xRW+%V)(^PM6kIqFH1u94PTjQjfjb3ekNZKXl(OjfTd$7=hY44TG zZ;bQ0u5Cx`hsv-1p66l)1HWGNd&%u<3)9O_)xV<$y(CAe_EdZK(-3^z`D77g3vak^ z7lmEtVJ%$S4u9y%0-{LTlxj*I_5R$!cV0^w{#H{q*a4Y9d>T89(e7GIwJsHL6@Nokk&4Aq?=_xqh?Xiol zGZ5CAT!s;1o!KRp8Z{302w8=@~m-3^%ou_r%H9B~3q_bW_cn$=2P3 z+IO$4_T|CZw#wC7wqf4W?+bs7v>!zo{jk}U?kKh2+3xdt{8*{EOE~!HeJ(%E%Rt^Y z7eDc>EbL$?X! zwN}Zmqg%8-@**^ae_>2e4p1nw}hJ*5vpdsvP$I7%bJz`zt@6Z((RpJIbx6 zoXa``Z6((JNTa+rb>&wd!tI_=x`%qo86yqj_PyH4F01m*>-oA6OHuZ z>Wqcv?2?MTC7T~vDZ4mmzi$DgWiP#13YGPJyO+^(FKF8O7~l{sxU)B(wxGo*R^LO) z9a(-T1*|&oNsRq(%bwUif|Mm}uNj3Qwtw==Ml3(aNvjK~DK1Dn2)v5KSU4?g$~DzA zU`zk(V3i+9F2+dx%4MakMo;$2jksh8_}SjfHDAN_9$o-ry#U+(z0c_DG~w*xqz zG=0P2j3w%zV|L4M*$n&l=7%e4L&wKIeSB4B)+E|l=y#xhooDIZi1 z{Nq0<7%+~f4S_)aGfS?5K%)O6Ir4s7+*BQ9zZPJzm5Fx;+UAP_w_m2vR9PlLaF+A8 zOQbHPn7@v1GRTXDY||7mIN%rvV$-qPsVW&Y@tLm=7(O|+SlRpRe1vF4=+g*t7nciNlq8@zlv1Bx%1<^xS z$ldZD5rV#HB+_E3yGPxFpANL3ZN_r(#bv{kfU_RF6uiGI9$-*#`tU27vG}6>8zSeCgsi#K9=@K==fR ziXd%2PXw^b@~oV6CBT;FZzRRvJ_ck&;l-w0G#H)qvwXV-(oFAGYTfYzJ>qq7FAX6u z%Jt3jp*sTWoVq5lEr!_hiKuTJV6N|Y%0>|*C|Ts__BEyfL!K$bnhppoS2cTB2tlyd z=;3 z`NTB|J;dF$niRJS(#(T>yV^}aPk!EYqRrJqCZbv@9a!EAB-S*^B84bU%1QYmvdS$v zJ^?9!5h=hB^yKN93EPT*xmdpb*GFiO#5z14<{Kk^C6DCyfrzwpPkw&|k+qdw9e0rP z31)V)7%*pGkT0!r4Z@apxhE%-A%0P$(=3P|GRvH;5=I((s*W2fMc&S{6_4b^L&n6) zuFljHkY;>#hr)=X2MPKy67US7r$m|w3(L^~4*zp7E%GW#i45aOXt25P-lqh9NmfwOrW7g-?`VQFeLWvM=JW;%B#Usgm^Qe9o@|>M zs3{r^l8;U^!dj02NZxy14bOu#J^c!Fnh+w-j6h_40A#XRs1Yz)5v1KCtg}n@qkz8% zZ;K_C3G`%1?4m>RXuw|d1{)rV6ojy;V?-vnYX$|J*N?FBkEbaCoSr`f=7jk`PsSes zOzt`cn74V{4uaK?t9-jw4W#)Z4;sAHzdiuqR>#5=L~k?>z+Eme3=rQ&0R{J^lV`1x zCBWRh$l-^WrPib1Y45#bK<+&|C}}l0a|`Qp4fLdo5iv(9Mo)l}EZs1EXw6?7NN&UY z%s{54Y<>*x(OP*n_>-Xsn$G z3f|)m+`t?S;hcj&Hu02*4sd>U97HP#_-KBF>rDW>aH>K~7l^kV1)+w2V|AbjMu1%l zOn-Sajm8+lE}JyHIdx)94rH0zv5X9nfz=XpMnIrSx`zSWcZtVAPtt`z*2wt@6wclP z=i{pm6#ZfKJKZ z$GVtvVxmWYL*H~=64UdbPoE&{k8Jg*k99fpK*V4nT?{&D(v1RJZ65a%fZVS}gr{9e zH3funq@cn5_6?8MsPlV?$jvZOn0myLc91NMf=&L^p3w(tokua?lgJ)}#){h)Cy@3l zW87g7IXpazT!A}%J(zjST=NJ&9mvv6c6_A5EbV{{UikWokZEYV{xDKvt0T+c{prbA zq@&NdEX&`3??HbhY>~~INn7?dJegYL3&234H7++9b3TADz;6i(ijUSub*j1YnG3Um zfmp(wqWX##zX`3b5UlX0?w01sLR;LZ6R=W&EF&)^Gg1nEda!|8`71QKpB+szyz>%h zz@rr2gh*`msX`kA!Yj4w5LQcHJ2L8f%Gl2Lr{L||?cnVnA%MR!)CmKMi!JIgz&7sj zGZ_$X{?ij!`7j1Rpm<}+mj`rQY!9LXR$jaH8UPggJ>UTx7Nx0B!zx7WG zwK&$2C(z`=5c{0fT)&})>Oblwrj$-kak0}a=7DjB zj-^uYXaGcf-E8su(N^KGMAoroFROCGs%Cf1>(PMX4d;5E>VsE?PG+)R`<-1stvg%Ns&b1T0wethZ z<)henX0K$?inSwN`+u_BsLt!!Y(Y#-F7?VbqyhoW1s6Q~(%+m|!yda#gvWJThWsN~tx5iKtDgErYx zZr?eV{&HKynxNTJ-G~>OgH0clD~(Q@{yP=jLm%h0zj&x_b+jwklBavcHS2Q%2Q|xg z>B=}OA9a^Td+pzT5c>-c%~Ts;V``uMPML~xqHM7-Gphwep*x%P0XG(uxkmtyQJ--p{t`#xnkC&9}aBE2msh5B}>pzEkb4&P0KXU zBr~&O7Lfh$l0%SP!0eyb)jA>&;I8M8TXvhw+_F_n4GRW;1V&u<=-D<2U`z#qU+n)X z(@m?V&J0*eX9Pghu$??>Q|NDc01}~}YY1nyo4VDGG;LY%JtBaJ53g0{6r{o^2boSMc}=uVguEqzGQf?A?3!f>DqXVnhnyU0Vt-l3Ar+r4|mEhMBXfv=2qJ zQ|XM^wcuO{JkY+gG;FVpkfo~0*RY9ie3I-AX6-0BGrKh_)` zo^o_4dQxV`ItIFRT|xYvqAv5^3$?r@b+a^j7d$bQ{|yf zPNgS%ijnleJK$6v@K74&{uv4g?}zM*Go4(7C;9+HoW=Xh+&jGphwaxa@T9!3z%%>9 z$FkT!flYK}a%k0;yeIz_KEaaCmg!px4};`EkvH&1fEJXkGVX@bKAb1`%go<*HzIfs zWxSa`2H7-#27;^vT;%{ckp3w65@D+NGJ?CRv`sref%mrh>oEQaFaSIUvU-uWF~|S_ N002ovPDHLkV1ky}OS1p~ 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..55c4e4252175 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, :tenant) + + 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 %> -
- <%= f.text_field :tenant, required: true, placeholder: 'common', container_class: '-middle' %> -
- <%= t('openid_connect.setting_instructions.azure_tenant_html') %> -
-
-
- <%= f.check_box :use_graph_api, container_class: '-middle' %> -
- <%= t('openid_connect.setting_instructions.azure_graph_api') %> -
-
- <% end %> -<% end %> diff --git a/modules/openid_connect/app/views/openid_connect/providers/_form.html.erb b/modules/openid_connect/app/views/openid_connect/providers/_form.html.erb deleted file mode 100644 index 046a0bbfefa2..000000000000 --- a/modules/openid_connect/app/views/openid_connect/providers/_form.html.erb +++ /dev/null @@ -1,43 +0,0 @@ -<% if @provider.persisted? && @provider.name == 'azure' && @provider.tenant.empty? %> -
-
-

<%= I18n.t('openid_connect.setting_instructions.azure_deprecation_warning') %>

-
-
-<% end %> - -
- <% unless @provider.persisted? -%> -
- <%= f.collection_select :name, - openid_connect_providers_available_for_configure, - :to_s, - :capitalize, - { container_class: '-middle', required: true }, - data: { - 'action': 'admin--openid-connect-providers#updateTypeForm' - } - %> -
- <% end -%> - -
- <%= f.text_field :display_name, required: false, container_class: '-middle' %> -
- -
- <%= f.text_field :identifier, required: true, container_class: '-middle' %> -
- -
- <%= f.text_field :secret, required: true, container_class: '-middle' %> -
- -
- <%= f.check_box :limit_self_registration, required: false, container_class: '-middle' %> -
- <%= I18n.t('openid_connect.setting_instructions.limit_self_registration') %> -
-
-
-<%= render partial: 'azure_form', locals: { f: } %> diff --git a/modules/openid_connect/app/views/openid_connect/providers/edit.html.erb b/modules/openid_connect/app/views/openid_connect/providers/edit.html.erb index fd27b98d44bf..d436f2fb0347 100644 --- a/modules/openid_connect/app/views/openid_connect/providers/edit.html.erb +++ b/modules/openid_connect/app/views/openid_connect/providers/edit.html.erb @@ -1,25 +1,36 @@ -<% page_title = t('openid_connect.providers.label_edit', name: @provider.name) %> -<% local_assigns[:additional_breadcrumb] = @provider.name %> +<% page_title = t('openid_connect.providers.label_edit', name: @provider.display_name) %> +<% local_assigns[:additional_breadcrumb] = @provider.display_name %> <% html_title(t(:label_administration), page_title) -%> <%= render Primer::OpenProject::PageHeader.new do |header| - header.with_title { @provider.name } + header.with_title { @provider.display_name } header.with_breadcrumbs([{ href: admin_index_path, text: t(:label_administration) }, { href: admin_settings_authentication_path, text: t(:label_authentication) }, { href: openid_connect_providers_path, text: t("openid_connect.providers.plural") }, - @provider.name]) + @provider.display_name]) + header.with_action_button( + tag: :a, + scheme: :danger, + mobile_icon: :trash, + mobile_label: t(:button_delete), + size: :medium, + href: openid_connect_provider_path(@provider), + aria: { label: I18n.t(:button_delete) }, + data: { + confirm: t(:text_are_you_sure), + method: :delete, + }, + title: I18n.t(:button_delete) + ) do |button| + button.with_leading_visual_icon(icon: :trash) + t(:button_delete) + end end %> -<%= error_messages_for @provider %> - -<%= labelled_tabular_form_for @provider, - html: { class: 'form', autocomplete: 'off' } do |f| %> - <%= render partial: "form", locals: { f: f } %> -

- <%= 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 From 2e22ab258b63090d832fbb8019ed5e22fb608b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 16:29:19 +0200 Subject: [PATCH 02/41] Remove has_actions --- .../app/components/openid_connect/providers/table_component.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c132e72cd3e1..9f23c1b974dc 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 @@ -16,7 +16,7 @@ def header_args(column) end def has_actions? - true + false end def sortable? From db8283017c6c02912b15a1147fb052f2d0302f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 16:36:21 +0200 Subject: [PATCH 03/41] Extend form validation --- .../openid_connect/providers/base_contract.rb | 25 +++++++++++++++++++ .../app/models/openid_connect/provider.rb | 4 +-- 2 files changed, 27 insertions(+), 2 deletions(-) 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 index e4efc55cdd0b..03f909a67bc0 100644 --- a/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb +++ b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb @@ -46,6 +46,31 @@ def self.model validates :metadata_url, url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, if: -> { model.metadata_url_changed? } + + attribute :authorization_endpoint + validates :authorization_endpoint, + url: { allow_blank: false, allow_nil: false, schemes: %w[http https] }, + if: -> { model.authorization_endpoint_changed? } + + attribute :userinfo_endpoint + validates :userinfo_endpoint, + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, + if: -> { model.userinfo_endpoint_changed? } + + attribute :token_endpoint + validates :token_endpoint, + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, + if: -> { model.token_endpoint_changed? } + + attribute :end_session_endpoint + validates :end_session_endpoint, + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, + if: -> { model.end_session_endpoint_changed? } + + attribute :jwks_uri + validates :jwks_uri, + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, + if: -> { model.jwks_uri_changed? } 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 9d5f5c4c8678..dd446950ecb6 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -1,6 +1,6 @@ module OpenIDConnect class Provider < AuthProvider - OIDC_PROVIDERS = ["google", "microsoft_entra", "custom"].freeze + OIDC_PROVIDERS = %w[google microsoft_entra custom].freeze DISCOVERABLE_ATTRIBUTES_ALL = %i[authorization_endpoint userinfo_endpoint token_endpoint @@ -8,7 +8,7 @@ class Provider < AuthProvider jwks_uri issuer].freeze DISCOVERABLE_ATTRIBUTES_OPTIONAL = %i[end_session_endpoint].freeze - DISCOVERABLE_ATTRIBUTES_MANDATORY = DISCOVERABLE_ATTRIBUTES_ALL - %i[end_session_endpoint] + DISCOVERABLE_ATTRIBUTES_MANDATORY = DISCOVERABLE_ATTRIBUTES_ALL - %i[end_session_endpoint jwks_uri] store_attribute :options, :oidc_provider, :string store_attribute :options, :metadata_url, :string From ef21f6ee5fbe1b51c4aa0d72b572e9fb5bcd6131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 16:43:00 +0200 Subject: [PATCH 04/41] Extract metadata_url getter --- .../providers/update_service.rb | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) 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 index 2ca3dacb9320..b3f0fe25e002 100644 --- a/modules/openid_connect/app/services/openid_connect/providers/update_service.rb +++ b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb @@ -42,14 +42,7 @@ class AttributesContract < Dry::Validation::Contract 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 + metadata_url = get_metadata_url(model) return call if metadata_url.blank? case (response = OpenProject.httpx.get(metadata_url)) @@ -78,11 +71,26 @@ def after_validate(_params, call) call.errors.add(:metadata_url, :response_is_not_successful, status: response.status) call.success = false in {error: error} - raise error + call.message = error.message + call.success = false + else + call.message = I18n.t(:notice_internal_server_error) + call.success = false end call end + + def get_metadata_url(model) + 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 + end end end end From e857bcdaf56c6f0d5e834849ef731cce569ea8db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 20:20:32 +0200 Subject: [PATCH 05/41] Add mapping --- config/locales/en.yml | 1 + .../app/forms/saml/providers/mapping_form.rb | 10 ++-- modules/auth_saml/config/locales/en.yml | 2 +- .../providers/attribute_mapping_form.rb | 48 +++++++++++++++++++ .../providers/view_component.html.erb | 25 +++++++++- .../openid_connect/providers/base_contract.rb | 4 ++ .../app/models/openid_connect/provider.rb | 7 +++ modules/openid_connect/config/locales/de.yml | 27 ----------- modules/openid_connect/config/locales/en.yml | 11 ++++- 9 files changed, 100 insertions(+), 35 deletions(-) create mode 100644 modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb delete mode 100644 modules/openid_connect/config/locales/de.yml diff --git a/config/locales/en.yml b/config/locales/en.yml index 9ac82e45a972..68e065616e0b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2374,6 +2374,7 @@ en: label_custom_favicon: "Custom favicon" label_custom_touch_icon: "Custom touch icon" label_logout: "Sign out" + label_mapping_for: "Mapping for: %{attribute}" label_main_menu: "Side Menu" label_manage: "Manage" label_manage_groups: "Manage groups" diff --git a/modules/auth_saml/app/forms/saml/providers/mapping_form.rb b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb index fa1be96a4795..518a680a774e 100644 --- a/modules/auth_saml/app/forms/saml/providers/mapping_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb @@ -32,7 +32,7 @@ class MappingForm < BaseForm form do |f| f.text_area( name: :mapping_login, - label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:login)), + label: I18n.t("label_mapping_for", attribute: User.human_attribute_name(:login)), caption: I18n.t("saml.instructions.mapping_login"), required: true, disabled: provider.seeded_from_env?, @@ -41,7 +41,7 @@ class MappingForm < BaseForm ) f.text_area( name: :mapping_mail, - label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:mail)), + label: I18n.t("label_mapping_for", attribute: User.human_attribute_name(:mail)), caption: I18n.t("saml.instructions.mapping_mail"), required: true, disabled: provider.seeded_from_env?, @@ -50,7 +50,7 @@ class MappingForm < BaseForm ) f.text_area( name: :mapping_firstname, - label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:first_name)), + label: I18n.t("label_mapping_for", attribute: User.human_attribute_name(:first_name)), caption: I18n.t("saml.instructions.mapping_firstname"), required: true, disabled: provider.seeded_from_env?, @@ -59,7 +59,7 @@ class MappingForm < BaseForm ) f.text_area( name: :mapping_lastname, - label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:last_name)), + label: I18n.t("label_mapping_for", attribute: User.human_attribute_name(:last_name)), caption: I18n.t("saml.instructions.mapping_lastname"), required: true, disabled: provider.seeded_from_env?, @@ -68,7 +68,7 @@ class MappingForm < BaseForm ) f.text_field( name: :mapping_uid, - label: I18n.t("saml.providers.label_mapping_for", attribute: I18n.t("saml.providers.label_uid")), + label: I18n.t("label_mapping_for", attribute: I18n.t("saml.providers.label_uid")), caption: I18n.t("saml.instructions.mapping_uid"), disabled: provider.seeded_from_env?, rows: 8, diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 62be3e7ff8f1..00d78f16416d 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -54,7 +54,7 @@ en: label_edit: Edit SAML identity provider %{name} label_uid: Internal user id label_mapping: Mapping - label_mapping_for: "Mapping for: %{attribute}" + label_requested_attribute_for: "Requested attribute for: %{attribute}" no_results_table: No SAML identity providers have been defined yet. plural: SAML identity providers diff --git a/modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb b/modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb new file mode 100644 index 000000000000..2c9717ba860b --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb @@ -0,0 +1,48 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenIDConnect + module Providers + class AttributeMappingForm < BaseForm + form do |f| + OpenIDConnect::Provider::MAPPABLE_ATTRIBUTES.each do |attr| + attribute_name = User.human_attribute_name(attr == :email ? :mail : attr) + + f.text_field( + name: "#{attr}_mapping", + label: I18n.t("label_mapping_for", attribute: attribute_name), + caption: I18n.t("openid_connect.instructions.mapping_#{attr}"), + disabled: provider.seeded_from_env?, + required: false, + input_width: :medium + ) + end + 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 index fd1337d1bd29..2db8e067f9f0 100644 --- 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 @@ -159,7 +159,7 @@ provider, form_class: ns::MetadataDetailsForm, edit_state:, - next_edit_state: :client_details, + next_edit_state: :attribute_mapping, edit_mode:, banner: provider.metadata_configured? ? t("openid_connect.providers.section_texts.configuration_metadata") : nil, banner_scheme: :default, @@ -178,6 +178,29 @@ end end + component.with_row(scheme: :default) do + if edit_state == :attribute_mapping + render(ns::Sections::FormComponent.new( + provider, + form_class: ns::AttributeMappingForm, + edit_state:, + next_edit_state: :client_details, + edit_mode:, + heading: nil + )) + else + render(ns::Sections::ShowComponent.new( + provider, + target_state: :attribute_mapping, + view_mode:, + heading: t("openid_connect.providers.label_attribute_mapping"), + description: t("openid_connect.providers.section_texts.attribute_mapping"), + label: provider.metadata_configured? ? t(:label_completed) : nil, + 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( 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 index 03f909a67bc0..afafe6c84dad 100644 --- a/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb +++ b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb @@ -71,6 +71,10 @@ def self.model validates :jwks_uri, url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, if: -> { model.jwks_uri_changed? } + + OpenIDConnect::Provider::MAPPABLE_ATTRIBUTES.each do |attr| + attribute :"mapping_#{attr}" + end 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 dd446950ecb6..9d6ac05bd26f 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -10,11 +10,18 @@ class Provider < AuthProvider DISCOVERABLE_ATTRIBUTES_OPTIONAL = %i[end_session_endpoint].freeze DISCOVERABLE_ATTRIBUTES_MANDATORY = DISCOVERABLE_ATTRIBUTES_ALL - %i[end_session_endpoint jwks_uri] + MAPPABLE_ATTRIBUTES = %i[login email first_name last_name].freeze + store_attribute :options, :oidc_provider, :string store_attribute :options, :metadata_url, :string + DISCOVERABLE_ATTRIBUTES_ALL.each do |attribute| store_attribute :options, attribute, :string end + MAPPABLE_ATTRIBUTES.each do |attribute| + store_attribute :options, "#{attribute}_mapping", :string + end + store_attribute :options, :client_id, :string store_attribute :options, :client_secret, :string store_attribute :options, :tenant, :string diff --git a/modules/openid_connect/config/locales/de.yml b/modules/openid_connect/config/locales/de.yml deleted file mode 100644 index b7a306417071..000000000000 --- a/modules/openid_connect/config/locales/de.yml +++ /dev/null @@ -1,27 +0,0 @@ -de: - logout_warning: > - Sie wurden ausgeloggt. Inhalte von Formularen, die sie abschicken möchten, - können verloren gehen. Bitte [loggen Sie sich wieder ein]. - activemodel: - attributes: - openid_connect/provider: - name: Name - display_name: Angezeigter Name - identifier: Identifier - secret: Secret - scope: Scope - limit_self_registration: Selbstregistrierung einschränken - openid_connect: - menu_title: OpenID-Provider - providers: - label_add_new: Einen neuen OpenID-Provider hinzufügen - label_edit: OpenID-Provider %{name} bearbeiten - no_results_table: Es wurden noch keine OpenID-Provider konfiguriert. - plural: OpenID-Provider - singular: OpenID-Provider - upsale: - description: Use existing OpenID credentials with OpenProject for easier access and interoperability with a range of other providers. - setting_instructions: - limit_self_registration: > - Wenn diese Option aktiv ist, können sich neue Nutzer mit diesem OpenID-Provider nur registrieren, - wenn die Selbstregistrierungs-Einstellung es erlaubt. diff --git a/modules/openid_connect/config/locales/en.yml b/modules/openid_connect/config/locales/en.yml index 7bede7ed3395..83965c96b137 100644 --- a/modules/openid_connect/config/locales/en.yml +++ b/modules/openid_connect/config/locales/en.yml @@ -21,7 +21,6 @@ en: 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: @@ -46,6 +45,14 @@ en: 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. + mapping_login: > + Provide a custom mapping in the userinfo response to be used for the login attribute. + mapping_email: > + Provide a custom mapping in the userinfo response to be used for the email attribute. + mapping_first_name: > + Provide a custom mapping in the userinfo response to be used for the first name. + mapping_last_name: > + Provide a custom mapping in the userinfo response to be used for the last name. settings: metadata_none: I don't have this information metadata_url: I have a discovery endpoint URL @@ -68,6 +75,7 @@ en: label_advanced_configuration: Advanced configuration label_configuration_details: Metadata label_client_details: Client details + label_attribute_mapping: Attribute mapping client_details_description: Configuration details of OpenProject as an OIDC client no_results_table: No providers have been defined yet. plural: OpenID providers @@ -80,6 +88,7 @@ en: 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. + attribute_mapping: Configure the mapping of attributes between OpenProject and the OpenID Connect provider. setting_instructions: limit_self_registration: > If enabled users can only register using this provider if the self registration setting allows for it. From 19eab3bfd5ad180a799f171ed3c15ca7eaff8d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 20:45:00 +0200 Subject: [PATCH 06/41] Same button layout as saml --- .../providers/sections/form_component.rb | 8 ++-- .../sections/metadata_form_component.rb | 3 -- .../providers/view_component.html.erb | 37 ++++++++++--------- 3 files changed, 25 insertions(+), 23 deletions(-) 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 index e1da600bae54..28ac2a6880af 100644 --- 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 @@ -68,10 +68,12 @@ def form_method end def button_label - if edit_mode - I18n.t(:button_continue) + return I18n.t(:button_save) unless edit_mode + + if next_edit_state.nil? + I18n.t(:button_finish_setup) else - I18n.t(:button_update) + I18n.t(:button_continue) 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 index 74e4ed983db8..b8ea2f88a11e 100644 --- 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 @@ -30,8 +30,5 @@ # 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/view_component.html.erb b/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb index 2db8e067f9f0..e6edcae42890 100644 --- 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 @@ -134,7 +134,11 @@ if edit_state == :metadata render(ns::Sections::MetadataFormComponent.new( provider, + form_class: nil, + heading: nil, + edit_state:, edit_mode:, + next_edit_state: :metadata_details )) else render(ns::Sections::ShowComponent.new( @@ -159,7 +163,7 @@ provider, form_class: ns::MetadataDetailsForm, edit_state:, - next_edit_state: :attribute_mapping, + next_edit_state: :client_details, edit_mode:, banner: provider.metadata_configured? ? t("openid_connect.providers.section_texts.configuration_metadata") : nil, banner_scheme: :default, @@ -179,47 +183,46 @@ end component.with_row(scheme: :default) do - if edit_state == :attribute_mapping + if edit_state == :client_details render(ns::Sections::FormComponent.new( provider, - form_class: ns::AttributeMappingForm, + form_class: ns::ClientDetailsForm, edit_state:, - next_edit_state: :client_details, + next_edit_state: :attribute_mapping, edit_mode:, heading: nil )) else render(ns::Sections::ShowComponent.new( provider, - target_state: :attribute_mapping, + target_state: :client_details, view_mode:, - heading: t("openid_connect.providers.label_attribute_mapping"), - description: t("openid_connect.providers.section_texts.attribute_mapping"), - label: provider.metadata_configured? ? t(:label_completed) : nil, - label_scheme: provider.metadata_configured? ? :success : :secondary + 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 component.with_row(scheme: :default) do - if edit_state == :client_details + if edit_state == :attribute_mapping render(ns::Sections::FormComponent.new( provider, - form_class: ns::ClientDetailsForm, + form_class: ns::AttributeMappingForm, edit_state:, - next_edit_state: :mapping, edit_mode:, heading: nil )) else render(ns::Sections::ShowComponent.new( provider, - target_state: :client_details, + target_state: :attribute_mapping, 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 + heading: t("openid_connect.providers.label_attribute_mapping"), + description: t("openid_connect.providers.section_texts.attribute_mapping"), + label: provider.metadata_configured? ? t(:label_completed) : nil, + label_scheme: provider.metadata_configured? ? :success : :secondary )) end end From 77b8da473059bfe1183b85fd5ebc910f2ad7c5d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 20:53:46 +0200 Subject: [PATCH 07/41] Don't walk through sections without edit_mode --- .../providers/sections/form_component.rb | 12 ++++++++++-- .../sections/metadata_form_component.html.erb | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) 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 index 28ac2a6880af..99a83b3788a0 100644 --- 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 @@ -53,9 +53,17 @@ def initialize(provider, def url if provider.new_record? - openid_connect_providers_path(edit_state:, edit_mode:, next_edit_state:) + openid_connect_providers_path(**form_url_params) else - openid_connect_provider_path(edit_state:, edit_mode:, next_edit_state:, id: provider.id) + openid_connect_provider_path(provider, **form_url_params) + end + end + + def form_url_params + if edit_mode + { edit_state:, edit_mode:, next_edit_state: } + else + { edit_state: } 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 index efe3f6dd7712..c1d86a4b6b56 100644 --- 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 @@ -2,7 +2,7 @@ primer_form_with( model: provider, id: "openid-connect-providers-edit-form", - url: openid_connect_provider_path(provider, edit_mode:, next_edit_state: :metadata_details), + url:, data: { controller: "show-when-value-selected", turbo: true, From a1dd5278f93b9b21d529ab8537b1b0c76cd6cf61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 21:04:36 +0200 Subject: [PATCH 08/41] Hash builder with mapping --- .../providers/attribute_mapping_form.rb | 2 +- .../app/models/openid_connect/provider.rb | 29 +----- .../openid_connect/provider/hash_builder.rb | 42 ++++++++ .../openid_connect/configuration_mapper.rb | 95 +++++++++++++++++++ 4 files changed, 141 insertions(+), 27 deletions(-) create mode 100644 modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb create mode 100644 modules/openid_connect/app/services/openid_connect/configuration_mapper.rb diff --git a/modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb b/modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb index 2c9717ba860b..1ed8eb37544a 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb +++ b/modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb @@ -34,7 +34,7 @@ class AttributeMappingForm < BaseForm attribute_name = User.human_attribute_name(attr == :email ? :mail : attr) f.text_field( - name: "#{attr}_mapping", + name: :"mapping_#{attr}", label: I18n.t("label_mapping_for", attribute: attribute_name), caption: I18n.t("openid_connect.instructions.mapping_#{attr}"), disabled: provider.seeded_from_env?, diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index 9d6ac05bd26f..692eb6816828 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -1,5 +1,7 @@ module OpenIDConnect class Provider < AuthProvider + include HashBuilder + OIDC_PROVIDERS = %w[google microsoft_entra custom].freeze DISCOVERABLE_ATTRIBUTES_ALL = %i[authorization_endpoint userinfo_endpoint @@ -19,7 +21,7 @@ class Provider < AuthProvider store_attribute :options, attribute, :string end MAPPABLE_ATTRIBUTES.each do |attribute| - store_attribute :options, "#{attribute}_mapping", :string + store_attribute :options, "mapping_#{attribute}", :string end store_attribute :options, :client_id, :string @@ -50,32 +52,7 @@ def configured? basic_details_configured? && advanced_details_configured? && metadata_configured? end - def to_h - 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 icon case oidc_provider diff --git a/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb b/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb new file mode 100644 index 000000000000..417d22eac481 --- /dev/null +++ b/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb @@ -0,0 +1,42 @@ +module OpenIDConnect + module Provider::HashBuilder + def attribute_map + OpenIDConnect::Provider::MAPPABLE_ATTRIBUTES + .index_with { |attr| public_send(:"mapping_#{attr}") } + .compact_blank + end + + def to_h # rubocop:disable Metrics/AbcSize + 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:, + attribute_map: + } + .merge(attribute_map) + .compact_blank + + 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 + end +end diff --git a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb new file mode 100644 index 000000000000..748745546989 --- /dev/null +++ b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb @@ -0,0 +1,95 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Saml + class ConfigurationMapper + attr_reader :configuration + + def initialize(configuration) + @configuration = configuration + end + + def call! + options = mapped_options(configuration.deep_stringify_keys) + { + "options" => options, + "slug" => options.delete("name"), + "display_name" => options.delete("display_name") || "SAML" + } + end + + private + + def mapped_options(options) + options["idp_sso_service_url"] ||= options.delete("idp_sso_target_url") + options["idp_slo_service_url"] ||= options.delete("idp_slo_target_url") + options["sp_entity_id"] ||= options.delete("issuer") + + build_idp_cert(options) + extract_security_options(options) + extract_mapping(options) + + options.compact + end + + def extract_mapping(options) + return unless options["attribute_statements"] + + options["mapping_login"] = extract_mapping_attribute(options, "login") + options["mapping_mail"] = extract_mapping_attribute(options, "email") + options["mapping_firstname"] = extract_mapping_attribute(options, "first_name") + options["mapping_lastname"] = extract_mapping_attribute(options, "last_name") + options["mapping_uid"] = extract_mapping_attribute(options, "uid") + end + + def extract_mapping_attribute(options, key) + value = options["attribute_statements"][key] + + if value.present? + Array(value).join("\n") + end + end + + def build_idp_cert(options) + if options["idp_cert"] + options["idp_cert"] = OneLogin::RubySaml::Utils.format_cert(options["idp_cert"]) + elsif options["idp_cert_multi"] + options["idp_cert"] = options["idp_cert_multi"]["signing"] + .map { |cert| OneLogin::RubySaml::Utils.format_cert(cert) } + .join("\n") + end + end + + def extract_security_options(options) + return unless options["security"] + + options.merge! options["security"].slice("authn_requests_signed", "want_assertions_signed", + "want_assertions_encrypted", "digest_method", "signature_method") + end + end +end From 4e897fb17e9b96ad5af4d964dd5e72dc33cc7b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 21:14:43 +0200 Subject: [PATCH 09/41] Allow custom icon --- .../openid_connect/providers/metadata_details_form.rb | 9 +++++++++ .../openid_connect/app/models/openid_connect/provider.rb | 5 ++--- modules/openid_connect/config/locales/en.yml | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) 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 index 49702255ae70..ec28d450750e 100644 --- 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 @@ -39,6 +39,15 @@ class MetadataDetailsForm < BaseForm input_width: :large ) end + + f.text_field( + name: :icon, + label: I18n.t("activemodel.attributes.openid_connect/provider.icon"), + caption: I18n.t("saml.instructions.icon"), + disabled: provider.seeded_from_env?, + required: false, + input_width: :large + ) 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 692eb6816828..17e50f95aea9 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -16,6 +16,7 @@ class Provider < AuthProvider store_attribute :options, :oidc_provider, :string store_attribute :options, :metadata_url, :string + store_attribute :options, :icon, :string DISCOVERABLE_ATTRIBUTES_ALL.each do |attribute| store_attribute :options, attribute, :string @@ -52,8 +53,6 @@ def configured? basic_details_configured? && advanced_details_configured? && metadata_configured? end - - def icon case oidc_provider when "google" @@ -61,7 +60,7 @@ def icon when "microsoft_entra" "openid_connect/auth_provider-azure.png" else - "openid_connect/auth_provider-custom.png" + super.presence || "openid_connect/auth_provider-custom.png" end end end diff --git a/modules/openid_connect/config/locales/en.yml b/modules/openid_connect/config/locales/en.yml index 83965c96b137..652bdf07f3ea 100644 --- a/modules/openid_connect/config/locales/en.yml +++ b/modules/openid_connect/config/locales/en.yml @@ -23,6 +23,7 @@ en: issuer: Issuer tenant: Tenant metadata_url: Metadata URL + icon: Custom icon activerecord: errors: models: From 980dd1cc615382c42f13f657305429231636a743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 21:22:53 +0200 Subject: [PATCH 10/41] Move migration to engine --- .../migrate/20240829140616_migrate_oidc_settings_to_providers.rb | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {db => modules/openid_connect/db}/migrate/20240829140616_migrate_oidc_settings_to_providers.rb (100%) diff --git a/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb b/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb similarity index 100% rename from db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb rename to modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb From 3b4ccfdf9af16c5d017e5ebb4c8fbade58534d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 21:23:40 +0200 Subject: [PATCH 11/41] Format migration text --- .../20240829140616_migrate_oidc_settings_to_providers.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb b/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb index edbaf98b5c7d..1396d0da9fec 100644 --- a/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb +++ b/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb @@ -60,10 +60,13 @@ def migrate_provider!(name, configuration) 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 From 533312bfd06af6e8aa049df817168c5774816fbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 21:28:45 +0200 Subject: [PATCH 12/41] Configuration mapper --- .../openid_connect/configuration_mapper.rb | 58 ++++++------------- .../services/openid_connect/sync_service.rb | 21 ++----- 2 files changed, 22 insertions(+), 57 deletions(-) diff --git a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb index 748745546989..59ad4826a0ce 100644 --- a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb +++ b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Saml +module OpenIDConnect class ConfigurationMapper attr_reader :configuration @@ -36,60 +36,38 @@ def initialize(configuration) def call! options = mapped_options(configuration.deep_stringify_keys) + { - "options" => options, "slug" => options.delete("name"), - "display_name" => options.delete("display_name") || "SAML" + "display_name" => options.delete("display_name") || "OpenID Connect", + "oidc_provider" => "custom", + "client_id" => options["identifier"], + "client_secret" => options["secret"], + "issuer" => options["issuer"], + "authorization_endpoint" => options["authorization_endpoint"], + "token_endpoint" => options["token_endpoint"], + "userinfo_endpoint" => options["userinfo_endpoint"], + "end_session_endpoint" => options["end_session_endpoint"], + "jwks_uri" => options["jwks_uri"] } end private def mapped_options(options) - options["idp_sso_service_url"] ||= options.delete("idp_sso_target_url") - options["idp_slo_service_url"] ||= options.delete("idp_slo_target_url") - options["sp_entity_id"] ||= options.delete("issuer") - - build_idp_cert(options) - extract_security_options(options) extract_mapping(options) options.compact end def extract_mapping(options) - return unless options["attribute_statements"] - - options["mapping_login"] = extract_mapping_attribute(options, "login") - options["mapping_mail"] = extract_mapping_attribute(options, "email") - options["mapping_firstname"] = extract_mapping_attribute(options, "first_name") - options["mapping_lastname"] = extract_mapping_attribute(options, "last_name") - options["mapping_uid"] = extract_mapping_attribute(options, "uid") - end - - def extract_mapping_attribute(options, key) - value = options["attribute_statements"][key] - - if value.present? - Array(value).join("\n") - end - end - - def build_idp_cert(options) - if options["idp_cert"] - options["idp_cert"] = OneLogin::RubySaml::Utils.format_cert(options["idp_cert"]) - elsif options["idp_cert_multi"] - options["idp_cert"] = options["idp_cert_multi"]["signing"] - .map { |cert| OneLogin::RubySaml::Utils.format_cert(cert) } - .join("\n") - end - end - - def extract_security_options(options) - return unless options["security"] + return unless options["attribute_map"] - options.merge! options["security"].slice("authn_requests_signed", "want_assertions_signed", - "want_assertions_encrypted", "digest_method", "signature_method") + options["mapping_login"] = options["attribute_map"]["login"] + options["mapping_mail"] = options["attribute_map"]["email"] + options["mapping_firstname"] = options["attribute_map"]["first_name"] + options["mapping_lastname"] = options["attribute_map"]["last_name"] + options["mapping_uid"] = options["attribute_map"]["uid"] 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 index 368bdbc5ef0b..73e5d5e8bcfa 100644 --- a/modules/openid_connect/app/services/openid_connect/sync_service.rb +++ b/modules/openid_connect/app/services/openid_connect/sync_service.rb @@ -32,34 +32,21 @@ class SyncService 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"] - } + @configuration = ::OpenIDConnect::ConfigurationMapper.new(configuration).call! end - def call + def call # rubocop:disable Metrics/AbcSize provider = ::OpenIDConnect::Provider.find_by(slug: name) if provider ::OpenIDConnect::Providers::UpdateService .new(model: provider, user: User.system) - .call(@provider_attributes) + .call(@configuration) .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) + .call(@configuration) .on_success { |call| call.message = "Successfully created OpenID provider #{name}." } .on_failure { |call| call.message = "Failed to create OpenID provider: #{call.message}" } end From 30fae2b090dc3d015283d0c4ded34960bf3ec673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 21:38:06 +0200 Subject: [PATCH 13/41] Rename seeder --- .../env_data/openid_connect/provider_seeder.rb | 4 ++-- .../lib/open_project/openid_connect.rb | 9 --------- .../lib/open_project/openid_connect/engine.rb | 16 +++++++++------- .../openid_connect/provider_seeder_spec.rb | 7 ++++++- 4 files changed, 17 insertions(+), 19 deletions(-) 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 index 207ba426c74b..861dd96206ad 100644 --- 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 @@ -31,7 +31,7 @@ module EnvData module OpenIDConnect class ProviderSeeder < Seeder def seed_data! - Setting.seed_openid_connect_provider.each do |name, configuration| + Setting.seed_oidc_provider.each do |name, configuration| print_status " ↳ Creating or Updating OpenID provider #{name}" do call = ::OpenIDConnect::SyncService.new(name, configuration).call @@ -45,7 +45,7 @@ def seed_data! end def applicable? - Setting.seed_openid_connect_provider.present? + Setting.seed_oidc_provider.present? 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 d82e29f7e2d5..859f3a34a01d 100644 --- a/modules/openid_connect/lib/open_project/openid_connect.rb +++ b/modules/openid_connect/lib/open_project/openid_connect.rb @@ -4,15 +4,6 @@ module OpenProject module OpenIDConnect - 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( 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 a9914ec6c6ac..c7e9cff0235e 100644 --- a/modules/openid_connect/lib/open_project/openid_connect/engine.rb +++ b/modules/openid_connect/lib/open_project/openid_connect/engine.rb @@ -60,13 +60,6 @@ class Engine < ::Rails::Engine end end - initializer "openid_connect.configure" do - ::Settings::Definition.add( - OpenProject::OpenIDConnect::CONFIG_KEY, - **OpenProject::OpenIDConnect::CONFIG_OPTIONS - ) - end - initializer "openid_connect.form_post_method" do # If response_mode 'form_post' is chosen, # the IP sends a POST to the callback. Only if @@ -81,6 +74,15 @@ class Engine < ::Rails::Engine end end + initializer "openid_connect.configuration" do + ::Settings::Definition.add :seed_oidc_provider, + description: "Provide a OIDC provider and sync its settings through ENV", + env_alias: "OPENPROJECT_OIDC", + writable: false, + default: {}, + format: :hash + end + config.to_prepare do ::OpenProject::OpenIDConnect::Hooks::Hook 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 index 96519c500c7b..0c75dabb2a09 100644 --- 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 @@ -36,7 +36,12 @@ subject(:seeder) { described_class.new(seed_data) } before do - reset(OpenProject::OpenIDConnect::CONFIG_KEY, **OpenProject::OpenIDConnect::CONFIG_OPTIONS) + reset(:seed_oidc_provider, + description: "Provide a OIDC provider and sync its settings through ENV", + env_alias: "OPENPROJECT_OPENID__CONNECT", + writable: false, + default: {}, + format: :hash) end context "when not provided" do From 7247a232ae36beaefefaaad1f9713be123368645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 21:41:17 +0200 Subject: [PATCH 14/41] Remove form_post security impact option This is probably no longer in place, but should be solved differently by now (by session mapping similar to saml RelayState) --- .../lib/open_project/openid_connect/engine.rb | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) 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 c7e9cff0235e..bde77b42ab7f 100644 --- a/modules/openid_connect/lib/open_project/openid_connect/engine.rb +++ b/modules/openid_connect/lib/open_project/openid_connect/engine.rb @@ -60,24 +60,10 @@ class Engine < ::Rails::Engine end end - initializer "openid_connect.form_post_method" do - # 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[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 - load Rails.root.join("config/initializers/secure_headers.rb") - end - end - initializer "openid_connect.configuration" do ::Settings::Definition.add :seed_oidc_provider, description: "Provide a OIDC provider and sync its settings through ENV", - env_alias: "OPENPROJECT_OIDC", + env_alias: "OPENPROJECT_OPENID__CONNECT", writable: false, default: {}, format: :hash From a72c0cb3d166ca42ee3a78c5df6cbd899887c9bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 09:06:33 +0200 Subject: [PATCH 15/41] Allow seeding with path config as shown in docs --- .../app/models/openid_connect/provider.rb | 2 +- .../openid_connect/provider_seeder.rb | 2 +- .../openid_connect/configuration_mapper.rb | 27 ++++++++++++++++--- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index 17e50f95aea9..06905634e297 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -32,7 +32,7 @@ class Provider < AuthProvider def self.slug_fragment = "oidc" def seeded_from_env? - (Setting.seed_openid_connect_provider || {}).key?(slug) + (Setting.seed_oidc_provider || {}).key?(slug) end def basic_details_configured? 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 index 861dd96206ad..732ff3bc3e48 100644 --- 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 @@ -33,7 +33,7 @@ class ProviderSeeder < Seeder def seed_data! Setting.seed_oidc_provider.each do |name, configuration| print_status " ↳ Creating or Updating OpenID provider #{name}" do - call = ::OpenIDConnect::SyncService.new(name, configuration).call + call = ::OpenIDConnect::SyncService.new(name, configuration.merge(name:)).call if call.success print_status " - #{call.message}" diff --git a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb index 59ad4826a0ce..bb56b5ec63b2 100644 --- a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb +++ b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb @@ -44,9 +44,9 @@ def call! "client_id" => options["identifier"], "client_secret" => options["secret"], "issuer" => options["issuer"], - "authorization_endpoint" => options["authorization_endpoint"], - "token_endpoint" => options["token_endpoint"], - "userinfo_endpoint" => options["userinfo_endpoint"], + "authorization_endpoint" => extract_url(options, "authorization_endpoint"), + "token_endpoint" => extract_url(options, "token_endpoint"), + "userinfo_endpoint" => extract_url(options, "userinfo_endpoint"), "end_session_endpoint" => options["end_session_endpoint"], "jwks_uri" => options["jwks_uri"] } @@ -54,6 +54,27 @@ def call! private + def extract_url(options, key) + value = options[key] + return value if value.start_with?('http') + unless value.start_with?("/") + raise ArgumentError.new("Provided #{key} '#{value}' needs to be http(s) URL or path starting with a slash.") + end + + URI + .join(base_url(options), value) + .to_s + end + + def base_url(options) + raise ArgumentError.new("Missing host in configuration") unless options["host"] + URI::Generic.build( + host: options["host"], + port: options["port"], + scheme: options["scheme"] || "https" + ).to_s + end + def mapped_options(options) extract_mapping(options) From 15490f22f15a9f91300aec98b187dc8452ee6d23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 09:23:33 +0200 Subject: [PATCH 16/41] Reintroduce sections, add one for mapping --- config/locales/en.yml | 1 + .../providers/view_component.html.erb | 28 +++++++++++-------- .../app/models/openid_connect/provider.rb | 6 ++++ modules/openid_connect/config/locales/en.yml | 2 +- 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 68e065616e0b..67c686f7d8f7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2141,6 +2141,7 @@ en: label_api_doc: "API documentation" label_backup: "Backup" label_backup_code: "Backup code" + label_basic_details: "Basic details" label_between: "between" label_blocked_by: "blocked by" label_blocks: "blocks" 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 index e6edcae42890..3291270d85ff 100644 --- 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 @@ -9,6 +9,10 @@ <% end %> <%= render(border_box_container) do |component| + component.with_header(color: :muted) do + render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t(:label_basic_details) } + end + case provider.oidc_provider when 'google' component.with_row(scheme: :default) do @@ -99,10 +103,6 @@ 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 @@ -126,9 +126,9 @@ 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: :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 @@ -153,9 +153,9 @@ 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: :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 @@ -205,6 +205,10 @@ end end + component.with_row(scheme: :neutral, color: :muted) do + render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t('openid_connect.providers.label_optional_configuration') } + end + component.with_row(scheme: :default) do if edit_state == :attribute_mapping render(ns::Sections::FormComponent.new( @@ -221,8 +225,8 @@ view_mode:, heading: t("openid_connect.providers.label_attribute_mapping"), description: t("openid_connect.providers.section_texts.attribute_mapping"), - label: provider.metadata_configured? ? t(:label_completed) : nil, - label_scheme: provider.metadata_configured? ? :success : :secondary + label: provider.mapping_configured? ? t(:label_completed) : nil, + label_scheme: provider.mapping_configured? ? :success : :secondary )) 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 06905634e297..d6faaed5d0c1 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -49,6 +49,12 @@ def metadata_configured? end end + def mapping_configured? + MAPPABLE_ATTRIBUTES.any? do |mandatory_attribute| + public_send(:"mapping_#{mandatory_attribute}").present? + end + end + def configured? basic_details_configured? && advanced_details_configured? && metadata_configured? end diff --git a/modules/openid_connect/config/locales/en.yml b/modules/openid_connect/config/locales/en.yml index 652bdf07f3ea..4a7bfe91daba 100644 --- a/modules/openid_connect/config/locales/en.yml +++ b/modules/openid_connect/config/locales/en.yml @@ -70,9 +70,9 @@ en: 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_optional_configuration: Optional configuration label_advanced_configuration: Advanced configuration label_configuration_details: Metadata label_client_details: Client details From 7ecd5df4721be6e306c2612dab2d5ad207965413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 10:56:10 +0200 Subject: [PATCH 17/41] Fix inflection --- .rubocop.yml | 1 + config/initializers/zeitwerk.rb | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.rubocop.yml b/.rubocop.yml index 821c3902e520..d45f3c613d7b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -248,6 +248,7 @@ RSpec/DescribeMethod: # to match the exact file name RSpec/SpecFilePathFormat: CustomTransform: + OpenIDConnect: openid_connect OAuthClients: oauth_clients IgnoreMethods: true diff --git a/config/initializers/zeitwerk.rb b/config/initializers/zeitwerk.rb index f346f581530a..5681349c4f81 100644 --- a/config/initializers/zeitwerk.rb +++ b/config/initializers/zeitwerk.rb @@ -34,6 +34,8 @@ "OAuth#{default_inflect($1, abspath)}" when /\A(.*)_oauth\z/ "#{default_inflect($1, abspath)}OAuth" + when "openid_connect" + "OpenIDConnect" when "oauth" "OAuth" when /\Aclamav_(.*)\z/ From 7c873ebbf201501dc158796b937cdb95783461d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 14:50:28 +0200 Subject: [PATCH 18/41] Extend spec to use absolute URL --- .../openid_connect/provider_seeder_spec.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 index 0c75dabb2a09..fb79e6291380 100644 --- 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 @@ -53,7 +53,7 @@ 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_HOST: "keycloak.local", OPENPROJECT_OPENID__CONNECT_KEYCLOAK_IDENTIFIER: "https://openproject.internal", OPENPROJECT_OPENID__CONNECT_KEYCLOAK_SECRET: "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", OPENPROJECT_OPENID__CONNECT_KEYCLOAK_ISSUER: "https://keycloak.local/realms/master", @@ -73,9 +73,9 @@ 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.authorization_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/auth" + expect(provider.token_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/token" + expect(provider.userinfo_endpoint).to eq "https://keycloak.local/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 @@ -96,9 +96,9 @@ 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.authorization_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/auth" + expect(provider.token_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/token" + expect(provider.userinfo_endpoint).to eq "https://keycloak.local/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 @@ -109,7 +109,7 @@ 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_HOST: "keycloak.local", OPENPROJECT_OPENID__CONNECT_KEYCLOAK_IDENTIFIER: "https://openproject.internal", OPENPROJECT_OPENID__CONNECT_KEYCLOAK_SECRET: "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", OPENPROJECT_OPENID__CONNECT_KEYCLOAK_ISSUER: "https://keycloak.local/realms/master", @@ -120,7 +120,7 @@ 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_HOST: "keycloak.local", OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_IDENTIFIER: "https://openproject.internal", OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_SECRET: "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_ISSUER: "https://keycloak.local/realms/master", From 6fe5456dc9723e23d0f2f80e56e238d9402e45f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 19:09:26 +0200 Subject: [PATCH 19/41] Feature spec --- .../providers/view_component.html.erb | 2 +- .../app/models/openid_connect/provider.rb | 2 + .../providers/update_service.rb | 3 +- modules/openid_connect/config/locales/en.yml | 4 +- .../administration/oidc_custom_crud_spec.rb | 156 ++++++++++ .../spec/fixtures/keycloak_localhost.json | 290 ++++++++++++++++++ 6 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 modules/openid_connect/spec/features/administration/oidc_custom_crud_spec.rb create mode 100644 modules/openid_connect/spec/fixtures/keycloak_localhost.json 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 index 3291270d85ff..2c6e794ee0a2 100644 --- 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 @@ -165,7 +165,7 @@ edit_state:, next_edit_state: :client_details, edit_mode:, - banner: provider.metadata_configured? ? t("openid_connect.providers.section_texts.configuration_metadata") : nil, + banner: provider.metadata_url.present? ? t("openid_connect.providers.section_texts.configuration_metadata") : nil, banner_scheme: :default, heading: nil )) diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index d6faaed5d0c1..05e6e1960979 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -44,6 +44,8 @@ def advanced_details_configured? end def metadata_configured? + return false unless metadata_url.present? + DISCOVERABLE_ATTRIBUTES_MANDATORY.all? do |mandatory_attribute| public_send(mandatory_attribute).present? 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 index b3f0fe25e002..689f73bae2fb 100644 --- a/modules/openid_connect/app/services/openid_connect/providers/update_service.rb +++ b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb @@ -50,7 +50,8 @@ def after_validate(_params, call) json = begin response.json rescue HTTPX::Error - call.errors.add(:metadata_url, :response_is_json) + binding.pry + call.errors.add(:metadata_url, :response_is_not_json) call.success = false end result = AttributesContract.new.call(json) diff --git a/modules/openid_connect/config/locales/en.yml b/modules/openid_connect/config/locales/en.yml index 4a7bfe91daba..5ab34f68ef52 100644 --- a/modules/openid_connect/config/locales/en.yml +++ b/modules/openid_connect/config/locales/en.yml @@ -66,9 +66,11 @@ en: name: Microsoft Entra custom: name: Custom + upsale: + description: Connect OpenProject to an OpenID connect identity provider label_add_new: Add a new OpenID provider label_edit: Edit OpenID provider %{name} - label_empty_title: No OIDC providers configured yet. + label_empty_title: No OpenID providers configured yet. label_empty_description: Add a provider to see them here. label_metadata: OpenID Connect Discovery Endpoint label_automatic_configuration: Automatic configuration diff --git a/modules/openid_connect/spec/features/administration/oidc_custom_crud_spec.rb b/modules/openid_connect/spec/features/administration/oidc_custom_crud_spec.rb new file mode 100644 index 000000000000..05b8c23b15aa --- /dev/null +++ b/modules/openid_connect/spec/features/administration/oidc_custom_crud_spec.rb @@ -0,0 +1,156 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_module_spec_helper + +RSpec.describe "OIDC administration CRUD", + :js, + :with_cuprite do + shared_let(:user) { create(:admin) } + + before do + login_as(user) + end + + context "with EE", with_ee: %i[sso_auth_providers] do + it "can manage OIDC providers through the UI" do + visit "/admin/openid_connect/providers" + expect(page).to have_text "No OpenID providers configured yet." + click_link_or_button "OpenID provider" + click_link_or_button "Custom" + + fill_in "Display name", with: "My provider" + click_link_or_button "Continue" + + # Skip metadata + click_link_or_button "Continue" + + # Fill out configuration + fill_in "Authorization endpoint", with: "https://example.com/sso" + fill_in "User information endpoint", with: "https://example.com/sso/userinfo" + fill_in "Token endpoint", with: "https://example.com/sso/token" + fill_in "Issuer", with: "foobar" + + click_link_or_button "Continue" + + # Client credentials + fill_in "Client ID", with: "client_id" + fill_in "Client secret", with: "client secret" + + click_link_or_button "Continue" + + # Mapping form + fill_in "Mapping for: Username", with: "login" + fill_in "Mapping for: Email", with: "mail" + fill_in "Mapping for: First name", with: "myName" + fill_in "Mapping for: Last name", with: "myLastName" + click_link_or_button "Finish setup" + + # We're now on the show page + within_test_selector("openid_connect_provider_metadata") do + expect(page).to have_text "Not configured" + end + + # Back to index + visit "/admin/openid_connect/providers" + expect(page).to have_text "My provider" + expect(page).to have_css(".users", text: 0) + expect(page).to have_css(".creator", text: user.name) + + click_link_or_button "My provider" + + provider = OpenIDConnect::Provider.find_by!(display_name: "My provider") + expect(provider.slug).to eq "oidc-my-provider" + expect(provider.authorization_endpoint).to eq "https://example.com/sso" + expect(provider.token_endpoint).to eq "https://example.com/sso/token" + expect(provider.userinfo_endpoint).to eq "https://example.com/sso/userinfo" + + expect(provider.issuer).to eq "foobar" + expect(provider.client_id).to eq "client_id" + expect(provider.client_secret).to eq "client secret" + + expect(provider.mapping_login).to eq "login" + expect(provider.mapping_email).to eq "mail" + expect(provider.mapping_first_name).to eq "myName" + expect(provider.mapping_last_name).to eq "myLastName" + + accept_confirm do + click_link_or_button "Delete" + end + + expect(page).to have_text "No OpenID providers configured yet." + end + + it "can import metadata from URL", :webmock do + visit "/admin/openid_connect/providers" + + click_link_or_button "OpenID provider" + click_link_or_button "Custom" + + fill_in "Display name", with: "My provider" + click_link_or_button "Continue" + + url = "https://example.com/metadata" + metadata = Rails.root.join("modules/openid_connect/spec/fixtures/keycloak_localhost.json").read + stub_request(:get, url).to_return(status: 200, body: metadata, headers: { "Content-Type" => "application/json" }) + + choose "I have a discovery endpoint URL" + fill_in "openid_connect_provider_metadata_url", with: url + + click_link_or_button "Continue" + expect(page).to have_text "The information has been pre-filled using the supplied discovery endpoint." + expect(page).to have_field "Authorization endpoint", with: "http://localhost:8080/realms/test/protocol/openid-connect/auth" + expect(page).to have_field "Token endpoint", with: "http://localhost:8080/realms/test/protocol/openid-connect/token" + expect(page).to have_field "User information endpoint", with: "http://localhost:8080/realms/test/protocol/openid-connect/userinfo" + expect(page).to have_field "End session endpoint", with: "http://localhost:8080/realms/test/protocol/openid-connect/logout" + expect(page).to have_field "Issuer", with: "http://localhost:8080/realms/test" + + expect(WebMock).to have_requested(:get, url) + end + + context "when provider exists already" do + let!(:provider) { create(:oidc_provider, display_name: "My provider") } + + it "shows an error trying to use the same name" do + visit "/admin/openid_connect/providers/new" + fill_in "Display name", with: "My provider" + click_link_or_button "Continue" + + expect(page).to have_text "Display name has already been taken." + end + end + end + + context "without EE", without_ee: %i[sso_auth_providers] do + it "renders the upsale page" do + visit "/admin/openid_connect/providers" + expect(page).to have_text "OpenID providers is an Enterprise add-on" + end + end +end diff --git a/modules/openid_connect/spec/fixtures/keycloak_localhost.json b/modules/openid_connect/spec/fixtures/keycloak_localhost.json new file mode 100644 index 000000000000..af2646381597 --- /dev/null +++ b/modules/openid_connect/spec/fixtures/keycloak_localhost.json @@ -0,0 +1,290 @@ +{ + "issuer": "http://localhost:8080/realms/test", + "authorization_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/auth", + "token_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/token", + "introspection_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/token/introspect", + "userinfo_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/userinfo", + "end_session_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/logout", + "frontchannel_logout_session_supported": true, + "frontchannel_logout_supported": true, + "jwks_uri": "http://localhost:8080/realms/test/protocol/openid-connect/certs", + "check_session_iframe": "http://localhost:8080/realms/test/protocol/openid-connect/login-status-iframe.html", + "grant_types_supported": [ + "authorization_code", + "implicit", + "refresh_token", + "password", + "client_credentials", + "urn:ietf:params:oauth:grant-type:device_code", + "urn:openid:params:grant-type:ciba" + ], + "acr_values_supported": [ + "0", + "1" + ], + "response_types_supported": [ + "code", + "none", + "id_token", + "token", + "id_token token", + "code id_token", + "code token", + "code id_token token" + ], + "subject_types_supported": [ + "public", + "pairwise" + ], + "id_token_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "id_token_encryption_alg_values_supported": [ + "RSA-OAEP", + "RSA-OAEP-256", + "RSA1_5" + ], + "id_token_encryption_enc_values_supported": [ + "A256GCM", + "A192GCM", + "A128GCM", + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512" + ], + "userinfo_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512", + "none" + ], + "userinfo_encryption_alg_values_supported": [ + "RSA-OAEP", + "RSA-OAEP-256", + "RSA1_5" + ], + "userinfo_encryption_enc_values_supported": [ + "A256GCM", + "A192GCM", + "A128GCM", + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512" + ], + "request_object_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512", + "none" + ], + "request_object_encryption_alg_values_supported": [ + "RSA-OAEP", + "RSA-OAEP-256", + "RSA1_5" + ], + "request_object_encryption_enc_values_supported": [ + "A256GCM", + "A192GCM", + "A128GCM", + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512" + ], + "response_modes_supported": [ + "query", + "fragment", + "form_post", + "query.jwt", + "fragment.jwt", + "form_post.jwt", + "jwt" + ], + "registration_endpoint": "http://localhost:8080/realms/test/clients-registrations/openid-connect", + "token_endpoint_auth_methods_supported": [ + "private_key_jwt", + "client_secret_basic", + "client_secret_post", + "tls_client_auth", + "client_secret_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "introspection_endpoint_auth_methods_supported": [ + "private_key_jwt", + "client_secret_basic", + "client_secret_post", + "tls_client_auth", + "client_secret_jwt" + ], + "introspection_endpoint_auth_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "authorization_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "authorization_encryption_alg_values_supported": [ + "RSA-OAEP", + "RSA-OAEP-256", + "RSA1_5" + ], + "authorization_encryption_enc_values_supported": [ + "A256GCM", + "A192GCM", + "A128GCM", + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512" + ], + "claims_supported": [ + "aud", + "sub", + "iss", + "auth_time", + "name", + "given_name", + "family_name", + "preferred_username", + "email", + "acr" + ], + "claim_types_supported": [ + "normal" + ], + "claims_parameter_supported": true, + "scopes_supported": [ + "openid", + "email", + "roles", + "microprofile-jwt", + "web-origins", + "profile", + "offline_access", + "phone", + "address", + "acr" + ], + "request_parameter_supported": true, + "request_uri_parameter_supported": true, + "require_request_uri_registration": true, + "code_challenge_methods_supported": [ + "plain", + "S256" + ], + "tls_client_certificate_bound_access_tokens": true, + "revocation_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/revoke", + "revocation_endpoint_auth_methods_supported": [ + "private_key_jwt", + "client_secret_basic", + "client_secret_post", + "tls_client_auth", + "client_secret_jwt" + ], + "revocation_endpoint_auth_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "backchannel_logout_supported": true, + "backchannel_logout_session_supported": true, + "device_authorization_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/auth/device", + "backchannel_token_delivery_modes_supported": [ + "poll", + "ping" + ], + "backchannel_authentication_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/ext/ciba/auth", + "backchannel_authentication_request_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "ES256", + "RS256", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "require_pushed_authorization_requests": false, + "pushed_authorization_request_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/ext/par/request", + "mtls_endpoint_aliases": { + "token_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/token", + "revocation_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/revoke", + "introspection_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/token/introspect", + "device_authorization_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/auth/device", + "registration_endpoint": "http://localhost:8080/realms/test/clients-registrations/openid-connect", + "userinfo_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/userinfo", + "pushed_authorization_request_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/ext/par/request", + "backchannel_authentication_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/ext/ciba/auth" + } +} From 7b8bbee839453a2748cd864c002951f0c07f180f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 19:14:15 +0200 Subject: [PATCH 20/41] Contract specs --- .../providers/create_contract_spec.rb | 49 ++++++++++++++++++ .../providers/delete_contract_spec.rb | 51 +++++++++++++++++++ .../providers/update_contract_spec.rb | 49 ++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 modules/openid_connect/spec/contracts/openid_connect/providers/create_contract_spec.rb create mode 100644 modules/openid_connect/spec/contracts/openid_connect/providers/delete_contract_spec.rb create mode 100644 modules/openid_connect/spec/contracts/openid_connect/providers/update_contract_spec.rb diff --git a/modules/openid_connect/spec/contracts/openid_connect/providers/create_contract_spec.rb b/modules/openid_connect/spec/contracts/openid_connect/providers/create_contract_spec.rb new file mode 100644 index 000000000000..36dd0cff327c --- /dev/null +++ b/modules/openid_connect/spec/contracts/openid_connect/providers/create_contract_spec.rb @@ -0,0 +1,49 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe OpenIDConnect::Providers::CreateContract do + include_context "ModelContract shared context" + + let(:provider) { build(:oidc_provider) } + let(:contract) { described_class.new provider, current_user } + + context "when admin" do + let(:current_user) { build_stubbed(:admin) } + + it_behaves_like "contract is valid" + end + + context "when non-admin" do + let(:current_user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end +end diff --git a/modules/openid_connect/spec/contracts/openid_connect/providers/delete_contract_spec.rb b/modules/openid_connect/spec/contracts/openid_connect/providers/delete_contract_spec.rb new file mode 100644 index 000000000000..d8d524f49a83 --- /dev/null +++ b/modules/openid_connect/spec/contracts/openid_connect/providers/delete_contract_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe OpenIDConnect::Providers::DeleteContract do + include_context "ModelContract shared context" + + let(:provider) { build_stubbed(:oidc_provider) } + let(:contract) { described_class.new provider, current_user } + + context "when admin" do + let(:current_user) { build_stubbed(:admin) } + + it_behaves_like "contract is valid" + end + + context "when non-admin" do + let(:current_user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end +end diff --git a/modules/openid_connect/spec/contracts/openid_connect/providers/update_contract_spec.rb b/modules/openid_connect/spec/contracts/openid_connect/providers/update_contract_spec.rb new file mode 100644 index 000000000000..a4b72c653195 --- /dev/null +++ b/modules/openid_connect/spec/contracts/openid_connect/providers/update_contract_spec.rb @@ -0,0 +1,49 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe OpenIDConnect::Providers::UpdateContract do + let(:provider) { build_stubbed(:oidc_provider) } + let(:contract) { described_class.new provider, current_user } + + include_context "ModelContract shared context" + + context "when admin" do + let(:current_user) { build_stubbed(:admin) } + + it_behaves_like "contract is valid" + end + + context "when non-admin" do + let(:current_user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end +end From fc4908cc39776bb80c201c19a52c146c87102423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 19:31:27 +0200 Subject: [PATCH 21/41] Config mapper spec --- .../openid_connect/configuration_mapper.rb | 10 +- .../configuration_mapper_spec.rb | 148 ++++++++++++++++++ 2 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 modules/openid_connect/spec/services/openid_connect/configuration_mapper_spec.rb diff --git a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb index bb56b5ec63b2..a3655056a8fc 100644 --- a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb +++ b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb @@ -47,16 +47,17 @@ def call! "authorization_endpoint" => extract_url(options, "authorization_endpoint"), "token_endpoint" => extract_url(options, "token_endpoint"), "userinfo_endpoint" => extract_url(options, "userinfo_endpoint"), - "end_session_endpoint" => options["end_session_endpoint"], - "jwks_uri" => options["jwks_uri"] - } + "end_session_endpoint" => extract_url(options, "end_session_endpoint"), + "jwks_uri" => extract_url(options, "jwks_uri") + }.compact end private def extract_url(options, key) value = options[key] - return value if value.start_with?('http') + return value if value.blank? || value.start_with?("http") + unless value.start_with?("/") raise ArgumentError.new("Provided #{key} '#{value}' needs to be http(s) URL or path starting with a slash.") end @@ -68,6 +69,7 @@ def extract_url(options, key) def base_url(options) raise ArgumentError.new("Missing host in configuration") unless options["host"] + URI::Generic.build( host: options["host"], port: options["port"], diff --git a/modules/openid_connect/spec/services/openid_connect/configuration_mapper_spec.rb b/modules/openid_connect/spec/services/openid_connect/configuration_mapper_spec.rb new file mode 100644 index 000000000000..e5dd3128f681 --- /dev/null +++ b/modules/openid_connect/spec/services/openid_connect/configuration_mapper_spec.rb @@ -0,0 +1,148 @@ +# 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 OpenIDConnect::ConfigurationMapper, type: :model do + let(:instance) { described_class.new(configuration) } + let(:result) { instance.call! } + + describe "display_name" do + subject { result["display_name"] } + + context "when provided" do + let(:configuration) { { display_name: "My OIDC Provider" } } + + it { is_expected.to eq("My OIDC Provider") } + end + + context "when not provided" do + let(:configuration) { {} } + + it { is_expected.to eq("OpenID Connect") } + end + end + + describe "slug" do + subject { result["slug"] } + + context "when provided from name" do + let(:configuration) { { name: "OIDCwat" } } + + it { is_expected.to eq("OIDCwat") } + end + + context "when not provided" do + let(:configuration) { {} } + + it { is_expected.to be_nil } + end + end + + describe "client_id" do + subject { result } + + context "when provided" do + let(:configuration) { { identifier: "foo" } } + + it { is_expected.to include("client_id" => "foo") } + end + + context "when not provided" do + let(:configuration) { { foo: "bar" } } + + it { is_expected.not_to have_key("client_id") } + end + end + + describe "client_secret" do + subject { result } + + context "when provided" do + let(:configuration) { { secret: "foo" } } + + it { is_expected.to include("client_secret" => "foo") } + end + + context "when not provided" do + let(:configuration) { { foo: "bar" } } + + it { is_expected.not_to have_key("client_secret") } + end + end + + describe "issuer" do + subject { result } + + context "when provided" do + let(:configuration) { { issuer: "foo" } } + + it { is_expected.to include("issuer" => "foo") } + end + + context "when not provided" do + let(:configuration) { { foo: "bar" } } + + it { is_expected.not_to have_key("issuer") } + end + end + + %w[authorization_endpoint token_endpoint userinfo_endpoint end_session_endpoint jwks_uri].each do |key| + describe "setting #{key}" do + subject { result } + + context "when provided as url" do + let(:configuration) { { key => "https://foo.example.com/sso" } } + + it { is_expected.to include(key => "https://foo.example.com/sso") } + end + + context "when provided as path without host" do + let(:configuration) { { key => "/foo" } } + + it "raises an error" do + expect { subject }.to raise_error("Missing host in configuration") + end + end + + context "when provided as path with host" do + let(:configuration) { { host: "example.com", scheme: "https", key => "/foo" } } + + it { is_expected.to include(key => "https://example.com/foo") } + end + + context "when not provided" do + let(:configuration) { { foo: "bar" } } + + it { is_expected.not_to have_key(key) } + end + end + end +end From 2f3e7daef8ce467c866ed4440352991599832b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 20:01:24 +0200 Subject: [PATCH 22/41] Service specs --- .../providers/update_service.rb | 5 +- .../spec/factories/oidc_provider_factory.rb | 1 - .../providers/create_service_spec.rb | 36 +++++ .../providers/set_attributes_service_spec.rb | 130 ++++++++++++++++++ .../providers/update_service_spec.rb | 36 +++++ 5 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 modules/openid_connect/spec/services/openid_connect/providers/create_service_spec.rb create mode 100644 modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb create mode 100644 modules/openid_connect/spec/services/openid_connect/providers/update_service_spec.rb 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 index 689f73bae2fb..68b366a15d15 100644 --- a/modules/openid_connect/app/services/openid_connect/providers/update_service.rb +++ b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb @@ -45,12 +45,15 @@ def after_validate(_params, call) metadata_url = get_metadata_url(model) return call if metadata_url.blank? + extract_metadata(call, metadata_url, model) + end + + def extract_metadata(call, metadata_url, model) # rubocop:disable Metrics/AbcSize case (response = OpenProject.httpx.get(metadata_url)) in {status: 200..299} json = begin response.json rescue HTTPX::Error - binding.pry call.errors.add(:metadata_url, :response_is_not_json) call.success = false end diff --git a/modules/openid_connect/spec/factories/oidc_provider_factory.rb b/modules/openid_connect/spec/factories/oidc_provider_factory.rb index bc5502c8dbf4..069df379891c 100644 --- a/modules/openid_connect/spec/factories/oidc_provider_factory.rb +++ b/modules/openid_connect/spec/factories/oidc_provider_factory.rb @@ -11,7 +11,6 @@ "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", diff --git a/modules/openid_connect/spec/services/openid_connect/providers/create_service_spec.rb b/modules/openid_connect/spec/services/openid_connect/providers/create_service_spec.rb new file mode 100644 index 000000000000..79f9518b4bb2 --- /dev/null +++ b/modules/openid_connect/spec/services/openid_connect/providers/create_service_spec.rb @@ -0,0 +1,36 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "services/base_services/behaves_like_create_service" + +RSpec.describe OpenIDConnect::Providers::CreateService, type: :model do + it_behaves_like "BaseServices create service" do + let(:factory) { :oidc_provider } + end +end diff --git a/modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb b/modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb new file mode 100644 index 000000000000..d348a60394b8 --- /dev/null +++ b/modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_module_spec_helper + +RSpec.describe OpenIDConnect::Providers::SetAttributesService, type: :model do + let(:current_user) { build_stubbed(:admin) } + + let(:instance) do + described_class.new(user: current_user, + model: model_instance, + contract_class:, + contract_options: {}) + end + + let(:call) { instance.call(params) } + + subject { call.result } + + describe "new instance" do + let(:model_instance) { OpenIDConnect::Provider.new(oidc_provider: 'custom', display_name: "foo") } + let(:contract_class) { OpenIDConnect::Providers::CreateContract } + + describe "default attributes" do + let(:params) { {} } + + it "sets all default attributes", :aggregate_failures do + expect(subject.display_name).to eq "foo" + expect(subject.slug).to eq "oidc-foo" + expect(subject.creator).to eq(current_user) + + expect(subject.mapping_email).to be_blank + expect(subject.mapping_first_name).to be_blank + expect(subject.mapping_last_name).to be_blank + expect(subject.mapping_login).to be_blank + end + end + + %i[token_endpoint metadata_url jwks_uri userinfo_endpoint end_session_endpoint].each do |url_attr| + describe "setting #{url_attr}" do + let(:params) do + { + url_attr => value + } + end + + context "when nil" do + let(:value) { nil } + + it "is valid" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.public_send(url_attr)).to be_nil + end + end + + context "when blank" do + let(:value) { "" } + + it "is valid" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.public_send(url_attr)).to eq "" + end + end + + context "when not a URL" do + let(:value) { "foo!" } + + it "is valid" do + expect(call).not_to be_success + expect(call.errors.details[url_attr]) + .to contain_exactly({ error: :url, value: }) + end + end + + context "when invalid scheme" do + let(:value) { "urn:some:info" } + + it "is valid" do + expect(call).not_to be_success + expect(call.errors.details[url_attr]) + .to contain_exactly({ error: :url, value: }) + end + end + + context "when valid" do + let(:value) { "https://foobar.example.com/slo" } + + it "is valid" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.public_send(url_attr)).to eq value + end + end + end + end + end +end diff --git a/modules/openid_connect/spec/services/openid_connect/providers/update_service_spec.rb b/modules/openid_connect/spec/services/openid_connect/providers/update_service_spec.rb new file mode 100644 index 000000000000..7acd051a8991 --- /dev/null +++ b/modules/openid_connect/spec/services/openid_connect/providers/update_service_spec.rb @@ -0,0 +1,36 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "services/base_services/behaves_like_update_service" + +RSpec.describe OpenIDConnect::Providers::UpdateService, type: :model do + it_behaves_like "BaseServices update service" do + let(:factory) { :oidc_provider } + end +end From 524a4597a3cd9279be53f3f6138103722fa42ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 07:14:27 +0200 Subject: [PATCH 23/41] Add claims --- config/locales/en.yml | 1 + lib/open_project/static/links.rb | 9 +++ .../openid_connect/providers/claims_form.rb | 66 +++++++++++++++++ .../providers/view_component.html.erb | 74 ++++++++++++------- .../openid_connect/providers/base_contract.rb | 16 ++++ .../openid_connect/providers_controller.rb | 3 +- .../app/models/openid_connect/provider.rb | 5 +- .../openid_connect/configuration_mapper.rb | 2 + .../providers/update_service.rb | 2 +- modules/openid_connect/config/locales/en.yml | 7 ++ .../administration/oidc_custom_crud_spec.rb | 7 ++ .../providers/set_attributes_service_spec.rb | 62 +++++++++++++++- .../users/register_user_service_spec.rb | 8 +- 13 files changed, 227 insertions(+), 35 deletions(-) create mode 100644 modules/openid_connect/app/components/openid_connect/providers/claims_form.rb diff --git a/config/locales/en.yml b/config/locales/en.yml index 67c686f7d8f7..41923099e2ae 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -962,6 +962,7 @@ en: not_a_datetime: "is not a valid date time." not_a_number: "is not a number." not_allowed: "is invalid because of missing permissions." + not_json: "is not a valid JSON object." not_an_integer: "is not an integer." not_an_iso_date: "is not a valid date. Required format: YYYY-MM-DD." not_same_project: "doesn't belong to the same project." diff --git a/lib/open_project/static/links.rb b/lib/open_project/static/links.rb index ea9d734b5d87..3cf20939e294 100644 --- a/lib/open_project/static/links.rb +++ b/lib/open_project/static/links.rb @@ -273,6 +273,15 @@ def static_links sysadmin_docs: { saml: { href: "https://www.openproject.org/docs/system-admin-guide/authentication/saml/" + }, + oidc: { + href: "https://www.openproject.org/docs/installation-and-operations/misc/custom-openid-connect-providers/" + }, + oidc_claims: { + href: "https://www.openproject.org/docs/installation-and-operations/misc/custom-openid-connect-providers/#claims" + }, + oidc_acr_values: { + href: "https://www.openproject.org/docs/installation-and-operations/misc/custom-openid-connect-providers/#non-essential-claims" } }, storage_docs: { diff --git a/modules/openid_connect/app/components/openid_connect/providers/claims_form.rb b/modules/openid_connect/app/components/openid_connect/providers/claims_form.rb new file mode 100644 index 000000000000..02722ffc0d1f --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/claims_form.rb @@ -0,0 +1,66 @@ +#-- 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 ClaimsForm < BaseForm + include Redmine::I18n + + form do |f| + f.text_area( + name: :claims, + rows: 10, + label: I18n.t("activemodel.attributes.openid_connect/provider.claims"), + caption: link_translate( + "openid_connect.instructions.claims", + links: { + docs_url: ::OpenProject::Static::Links[:sysadmin_docs][:oidc_claims][:href] + } + ), + disabled: provider.seeded_from_env?, + required: false, + input_width: :large + ) + + f.text_field( + name: :acr_values, + label: I18n.t("activemodel.attributes.openid_connect/provider.acr_values"), + caption: link_translate( + "openid_connect.instructions.acr_values", + links: { + docs_url: ::OpenProject::Static::Links[:sysadmin_docs][:oidc_acr_values][:href] + } + ), + disabled: provider.seeded_from_env?, + required: false, + input_width: :large + ) + 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 index 2c6e794ee0a2..585282184884 100644 --- 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 @@ -1,4 +1,3 @@ -<% ns = OpenIDConnect::Providers %> <%= component_wrapper do %> <% if provider.seeded_from_env? %> <%= @@ -17,16 +16,16 @@ when 'google' component.with_row(scheme: :default) do basic_details_component = if edit_state == :name - ns::Sections::FormComponent.new( + OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::NameInputForm, + form_class: OpenIDConnect::Providers::NameInputForm, edit_state:, next_edit_state: :client_details, edit_mode:, heading: nil ) else - ns::Sections::ShowComponent.new( + OpenIDConnect::Providers::Sections::ShowComponent.new( provider, view_mode:, target_state: :name, @@ -39,15 +38,15 @@ component.with_row(scheme: :default) do if edit_state == :client_details - render(ns::Sections::FormComponent.new( + render(OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::ClientDetailsForm, + form_class: OpenIDConnect::Providers::ClientDetailsForm, edit_state:, edit_mode:, heading: nil, )) else - render(ns::Sections::ShowComponent.new( + render(OpenIDConnect::Providers::Sections::ShowComponent.new( provider, target_state: :client_details, view_mode:, @@ -61,16 +60,16 @@ when 'microsoft_entra' component.with_row(scheme: :default) do basic_details_component = if edit_state == :name - ns::Sections::FormComponent.new( + OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::NameInputAndTenantForm, + form_class: OpenIDConnect::Providers::NameInputAndTenantForm, edit_state:, next_edit_state: :client_details, edit_mode:, heading: nil ) else - ns::Sections::ShowComponent.new( + OpenIDConnect::Providers::Sections::ShowComponent.new( provider, view_mode:, target_state: :name, @@ -83,15 +82,15 @@ component.with_row(scheme: :default) do if edit_state == :client_details - render(ns::Sections::FormComponent.new( + render(OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::ClientDetailsForm, + form_class: OpenIDConnect::Providers::ClientDetailsForm, edit_state:, edit_mode:, heading: nil )) else - render(ns::Sections::ShowComponent.new( + render(OpenIDConnect::Providers::Sections::ShowComponent.new( provider, target_state: :client_details, view_mode:, @@ -106,16 +105,16 @@ component.with_row(scheme: :default) do basic_details_component = if edit_state == :name - ns::Sections::FormComponent.new( + OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::NameInputForm, + form_class: OpenIDConnect::Providers::NameInputForm, edit_state:, next_edit_state: :metadata, edit_mode:, heading: nil ) else - ns::Sections::ShowComponent.new( + OpenIDConnect::Providers::Sections::ShowComponent.new( provider, view_mode:, target_state: :name, @@ -132,7 +131,7 @@ component.with_row(scheme: :default) do if edit_state == :metadata - render(ns::Sections::MetadataFormComponent.new( + render(OpenIDConnect::Providers::Sections::MetadataFormComponent.new( provider, form_class: nil, heading: nil, @@ -141,7 +140,7 @@ next_edit_state: :metadata_details )) else - render(ns::Sections::ShowComponent.new( + render(OpenIDConnect::Providers::Sections::ShowComponent.new( provider, target_state: :metadata, view_mode:, @@ -159,9 +158,9 @@ component.with_row(scheme: :default) do if edit_state == :metadata_details - render(ns::Sections::FormComponent.new( + render(OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::MetadataDetailsForm, + form_class: OpenIDConnect::Providers::MetadataDetailsForm, edit_state:, next_edit_state: :client_details, edit_mode:, @@ -170,7 +169,7 @@ heading: nil )) else - render(ns::Sections::ShowComponent.new( + render(OpenIDConnect::Providers::Sections::ShowComponent.new( provider, target_state: :metadata_details, view_mode:, @@ -184,16 +183,16 @@ component.with_row(scheme: :default) do if edit_state == :client_details - render(ns::Sections::FormComponent.new( + render(OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::ClientDetailsForm, + form_class: OpenIDConnect::Providers::ClientDetailsForm, edit_state:, next_edit_state: :attribute_mapping, edit_mode:, heading: nil )) else - render(ns::Sections::ShowComponent.new( + render(OpenIDConnect::Providers::Sections::ShowComponent.new( provider, target_state: :client_details, view_mode:, @@ -211,15 +210,16 @@ component.with_row(scheme: :default) do if edit_state == :attribute_mapping - render(ns::Sections::FormComponent.new( + render(OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::AttributeMappingForm, + form_class: OpenIDConnect::Providers::AttributeMappingForm, edit_state:, + next_edit_state: :claims, edit_mode:, heading: nil )) else - render(ns::Sections::ShowComponent.new( + render(OpenIDConnect::Providers::Sections::ShowComponent.new( provider, target_state: :attribute_mapping, view_mode:, @@ -230,6 +230,26 @@ )) end end + + component.with_row(scheme: :default) do + if edit_state == :claims + render(OpenIDConnect::Providers::Sections::FormComponent.new( + provider, + form_class: OpenIDConnect::Providers::ClaimsForm, + edit_state:, + edit_mode:, + heading: nil + )) + else + render(OpenIDConnect::Providers::Sections::ShowComponent.new( + provider, + target_state: :claims, + view_mode:, + heading: t("activemodel.attributes.openid_connect/provider.claims"), + description: t("openid_connect.providers.section_texts.claims") + )) + end + end 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 index afafe6c84dad..f6bb171b0003 100644 --- a/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb +++ b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb @@ -42,6 +42,12 @@ def self.model attribute :slug attribute :options attribute :limit_self_registration + + attribute :claims + validate :claims_are_json + + attribute :acr_values + attribute :metadata_url validates :metadata_url, url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, @@ -75,6 +81,16 @@ def self.model OpenIDConnect::Provider::MAPPABLE_ATTRIBUTES.each do |attr| attribute :"mapping_#{attr}" end + + private + + def claims_are_json + return if claims.blank? + + JSON.parse(claims) + rescue JSON::ParserError + errors.add(:claims, :not_json) + end 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 55c4e4252175..c5ba074407e0 100644 --- a/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb +++ b/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb @@ -47,7 +47,8 @@ def edit; end def update update_params = params .require(:openid_connect_provider) - .permit(:display_name, :oidc_provider, :limit_self_registration, *OpenIDConnect::Provider.stored_attributes[:options]) + .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) diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index 05e6e1960979..03f644071ef4 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -29,6 +29,9 @@ class Provider < AuthProvider store_attribute :options, :client_secret, :string store_attribute :options, :tenant, :string + store_attribute :options, :claims, :string + store_attribute :options, :acr_values, :string + def self.slug_fragment = "oidc" def seeded_from_env? @@ -44,7 +47,7 @@ def advanced_details_configured? end def metadata_configured? - return false unless metadata_url.present? + return false if metadata_url.blank? DISCOVERABLE_ATTRIBUTES_MANDATORY.all? do |mandatory_attribute| public_send(mandatory_attribute).present? diff --git a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb index a3655056a8fc..dc57588397d7 100644 --- a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb +++ b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb @@ -44,6 +44,8 @@ def call! "client_id" => options["identifier"], "client_secret" => options["secret"], "issuer" => options["issuer"], + "claims" => options["claims"], + "acr_values" => options["acr_values"], "authorization_endpoint" => extract_url(options, "authorization_endpoint"), "token_endpoint" => extract_url(options, "token_endpoint"), "userinfo_endpoint" => extract_url(options, "userinfo_endpoint"), 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 index 68b366a15d15..3297b5109b2b 100644 --- a/modules/openid_connect/app/services/openid_connect/providers/update_service.rb +++ b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb @@ -68,7 +68,7 @@ def extract_metadata(call, metadata_url, model) # rubocop:disable Metrics/AbcSiz else call.errors.add(:metadata_url, :response_misses_required_attributes, - missing_attributes: result.errors.to_h.keys.join(", ")) + missing_attributes: result.errors.attribute_names.join(", ")) call.success = false end in {status: 300..} diff --git a/modules/openid_connect/config/locales/en.yml b/modules/openid_connect/config/locales/en.yml index 5ab34f68ef52..5cf78c3bd806 100644 --- a/modules/openid_connect/config/locales/en.yml +++ b/modules/openid_connect/config/locales/en.yml @@ -24,6 +24,8 @@ en: tenant: Tenant metadata_url: Metadata URL icon: Custom icon + claims: Claims + acr_values: ACR values activerecord: errors: models: @@ -46,6 +48,10 @@ en: 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. + claims: > + You can request additional claims for the userinfo and id token endpoints. Please see [our OpenID connect documentation](docs_url) for more information. + acr_values: > + Request non-essential claims in an easier format. See [our documentation on acr_values](docs_url) for more information. mapping_login: > Provide a custom mapping in the userinfo response to be used for the login attribute. mapping_email: > @@ -92,6 +98,7 @@ en: configuration: Configuration details of the OpenID Connect provider display_name: The display name visible to users. attribute_mapping: Configure the mapping of attributes between OpenProject and the OpenID Connect provider. + claims: Request additional claims for the ID token or userinfo response. setting_instructions: 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/spec/features/administration/oidc_custom_crud_spec.rb b/modules/openid_connect/spec/features/administration/oidc_custom_crud_spec.rb index 05b8c23b15aa..dee4f64db892 100644 --- a/modules/openid_connect/spec/features/administration/oidc_custom_crud_spec.rb +++ b/modules/openid_connect/spec/features/administration/oidc_custom_crud_spec.rb @@ -70,6 +70,13 @@ fill_in "Mapping for: Email", with: "mail" fill_in "Mapping for: First name", with: "myName" fill_in "Mapping for: Last name", with: "myLastName" + + click_link_or_button "Continue" + + # Claims + fill_in "Claims", with: '{"foo": "bar"}' + fill_in "ACR values", with: "foo bar" + click_link_or_button "Finish setup" # We're now on the show page diff --git a/modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb b/modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb index d348a60394b8..88975b615814 100644 --- a/modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb +++ b/modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb @@ -46,7 +46,7 @@ subject { call.result } describe "new instance" do - let(:model_instance) { OpenIDConnect::Provider.new(oidc_provider: 'custom', display_name: "foo") } + let(:model_instance) { OpenIDConnect::Provider.new(oidc_provider: "custom", display_name: "foo") } let(:contract_class) { OpenIDConnect::Providers::CreateContract } describe "default attributes" do @@ -64,6 +64,66 @@ end end + describe "setting claims" do + let(:params) do + { + claims: value + } + end + + context "when nil" do + let(:value) { nil } + + it "is valid" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.claims).to be_nil + end + end + + context "when blank" do + let(:value) { "" } + + it "is valid" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.claims).to eq "" + end + end + + context "when invalid JSON" do + let(:value) { "foo" } + + it "is invalid" do + expect(call).not_to be_success + expect(call.errors.details[:claims]) + .to contain_exactly({ error: :not_json }) + end + end + + context "when valid JSON" do + let(:value) do + { + id_token: { + acr: { + essential: true, + values: %w[phr phrh Multi_Factor] + } + } + }.to_json + end + + it "is valid" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.claims).to eq value + end + end + end + %i[token_endpoint metadata_url jwks_uri userinfo_endpoint end_session_endpoint].each do |url_attr| describe "setting #{url_attr}" do let(:params) do diff --git a/spec/services/users/register_user_service_spec.rb b/spec/services/users/register_user_service_spec.rb index 9d98aa9db5c8..9a8cfa2f1458 100644 --- a/spec/services/users/register_user_service_spec.rb +++ b/spec/services/users/register_user_service_spec.rb @@ -102,7 +102,7 @@ def with_all_registration_options(except: []) self_registration: 0, } do it "fails to activate due to disabled self registration" do - create(:oidc_provider, slug: 'azure') + create(:oidc_provider, slug: "azure") call = instance.call expect(call).not_to be_success expect(call.result).to eq user @@ -115,7 +115,7 @@ def with_all_registration_options(except: []) self_registration: 2, } do it "registers the user, but does not activate it" do - create(:oidc_provider, slug: 'azure') + create(:oidc_provider, slug: "azure") call = instance.call expect(call).to be_success expect(call.result).to eq user @@ -130,7 +130,7 @@ def with_all_registration_options(except: []) self_registration: 1, } do it "registers the user, but does not activate it" do - create(:oidc_provider, slug: 'azure') + create(:oidc_provider, slug: "azure") call = instance.call expect(call).to be_success @@ -146,7 +146,7 @@ def with_all_registration_options(except: []) self_registration: 3, } do it "activates the user" do - create(:oidc_provider, slug: 'azure') + create(:oidc_provider, slug: "azure") call = instance.call expect(call).to be_success expect(call.result).to eq user From dcc293c8a48151af2e3daefc6de39913f1810c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 09:06:16 +0200 Subject: [PATCH 24/41] Remove metadata_url from discoverable attribute check It is not relevant for the configured? check --- modules/openid_connect/app/models/openid_connect/provider.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index 03f644071ef4..4d14fa366581 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -47,8 +47,6 @@ def advanced_details_configured? end def metadata_configured? - return false if metadata_url.blank? - DISCOVERABLE_ATTRIBUTES_MANDATORY.all? do |mandatory_attribute| public_send(mandatory_attribute).present? end From 3312cc95d913f4eaebd9d5bc1a7a28093f5a543d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 10:38:53 +0200 Subject: [PATCH 25/41] Allow path based attributes after all --- .../openid_connect/providers/base_contract.rb | 39 +++++-------------- 1 file changed, 10 insertions(+), 29 deletions(-) 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 index f6bb171b0003..6f9f1e4b797e 100644 --- a/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb +++ b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb @@ -48,35 +48,12 @@ def self.model attribute :acr_values - attribute :metadata_url - validates :metadata_url, - url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, - if: -> { model.metadata_url_changed? } - - attribute :authorization_endpoint - validates :authorization_endpoint, - url: { allow_blank: false, allow_nil: false, schemes: %w[http https] }, - if: -> { model.authorization_endpoint_changed? } - - attribute :userinfo_endpoint - validates :userinfo_endpoint, - url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, - if: -> { model.userinfo_endpoint_changed? } - - attribute :token_endpoint - validates :token_endpoint, - url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, - if: -> { model.token_endpoint_changed? } - - attribute :end_session_endpoint - validates :end_session_endpoint, - url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, - if: -> { model.end_session_endpoint_changed? } - - attribute :jwks_uri - validates :jwks_uri, - url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, - if: -> { model.jwks_uri_changed? } + %i[metadata_url authorization_endpoint userinfo_endpoint token_endpoint end_session_endpoint jwks_uri].each do |attr| + attribute attr + validates attr, + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, + if: -> { model.public_send(:"#{attr}_changed?") && !path_attribute?(model.public_send(attr)) } + end OpenIDConnect::Provider::MAPPABLE_ATTRIBUTES.each do |attr| attribute :"mapping_#{attr}" @@ -84,6 +61,10 @@ def self.model private + def path_attribute?(attr) + attr.blank? || attr.start_with?("/") + end + def claims_are_json return if claims.blank? From 718d934875a2e834be0f8f45b53c911375f78fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 11:06:30 +0200 Subject: [PATCH 26/41] Fix generation of provider classes from new config --- .../app/models/openid_connect/provider.rb | 3 ++ .../openid_connect/provider/hash_builder.rb | 35 +++++++++++-------- .../openid_connect/configuration_mapper.rb | 26 +++++++++++--- .../providers/set_attributes_service.rb | 22 ++++++++++++ .../services/openid_connect/sync_service.rb | 1 + ...0616_migrate_oidc_settings_to_providers.rb | 1 - .../lib/open_project/openid_connect.rb | 31 ++++++++++++---- 7 files changed, 93 insertions(+), 26 deletions(-) diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index 4d14fa366581..cd03b98f626e 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -32,6 +32,9 @@ class Provider < AuthProvider store_attribute :options, :claims, :string store_attribute :options, :acr_values, :string + # azure specific option + store_attribute :options, :use_graph_api, :boolean + def self.slug_fragment = "oidc" def seeded_from_env? diff --git a/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb b/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb index 417d22eac481..5e59c0178956 100644 --- a/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb +++ b/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb @@ -7,14 +7,14 @@ def attribute_map end def to_h # rubocop:disable Metrics/AbcSize - h = { + { name: slug, + oidc_provider:, icon:, display_name:, userinfo_endpoint:, authorization_endpoint:, jwks_uri:, - host: URI(issuer).host, issuer:, identifier: client_id, secret: client_secret, @@ -22,21 +22,26 @@ def to_h # rubocop:disable Metrics/AbcSize limit_self_registration:, end_session_endpoint:, attribute_map: - } - .merge(attribute_map) - .compact_blank + }.merge(attribute_map) + .merge(provider_specific_to_h) + .compact_blank + end - if oidc_provider == "google" - h.merge!( - { - client_auth_method: :not_basic, - send_nonce: false, # use state instead of nonce - state: lambda { SecureRandom.hex(42) } - } - ) + def provider_specific_to_h + case oidc_provider + when "google" + { + client_auth_method: :not_basic, + send_nonce: false, # use state instead of nonce + state: lambda { SecureRandom.hex(42) } + } + when "microsoft_entra" + { + use_graph_api: + } + else + {} end - - h end end end diff --git a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb index dc57588397d7..b5495afb0048 100644 --- a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb +++ b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb @@ -34,17 +34,18 @@ def initialize(configuration) @configuration = configuration end - def call! + def call! # rubocop:disable Metrics/AbcSize options = mapped_options(configuration.deep_stringify_keys) { - "slug" => options.delete("name"), - "display_name" => options.delete("display_name") || "OpenID Connect", - "oidc_provider" => "custom", + "slug" => options["name"], + "display_name" => options["display_name"].presence || "OpenID Connect", + "oidc_provider" => oidc_provider(options), "client_id" => options["identifier"], "client_secret" => options["secret"], "issuer" => options["issuer"], "claims" => options["claims"], + "use_graph_api" => options["use_graph_api"], "acr_values" => options["acr_values"], "authorization_endpoint" => extract_url(options, "authorization_endpoint"), "token_endpoint" => extract_url(options, "token_endpoint"), @@ -56,6 +57,17 @@ def call! private + def oidc_provider(options) + case options["name"] + when /azure/ + "microsoft_entra" + when /google/ + "google" + else + "custom" + end + end + def extract_url(options, key) value = options[key] return value if value.blank? || value.start_with?("http") @@ -64,6 +76,12 @@ def extract_url(options, key) raise ArgumentError.new("Provided #{key} '#{value}' needs to be http(s) URL or path starting with a slash.") end + # Allow returning the value as is for built-in providers + # with fixed host names + if oidc_provider(options) != "custom" + return value + end + URI .join(base_url(options), value) .to_s 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 index 92e90fe00200..ec1879e64a01 100644 --- 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 @@ -37,6 +37,28 @@ def set_default_attributes(*) model.slug ||= "#{model.class.slug_fragment}-#{model.display_name.to_url}" if model.display_name end end + + def set_attributes(params) + update_options(params.delete(:options)) if params.key?(:options) + + super + + update_available_state + end + + def update_available_state + model.change_by_system do + model.available = model.configured? + end + end + + def update_options(options) + options + .select { |key, _| Saml::Provider.stored_attributes[:options].include?(key.to_s) } + .each do |key, value| + model.public_send(:"#{key}=", value) + end + 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 index 73e5d5e8bcfa..093b944ba682 100644 --- a/modules/openid_connect/app/services/openid_connect/sync_service.rb +++ b/modules/openid_connect/app/services/openid_connect/sync_service.rb @@ -32,6 +32,7 @@ class SyncService def initialize(name, configuration) @name = name + configuration[:name] = name @configuration = ::OpenIDConnect::ConfigurationMapper.new(configuration).call! end diff --git a/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb b/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb index 1396d0da9fec..46ea3ba0545e 100644 --- a/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb +++ b/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb @@ -34,7 +34,6 @@ def up return if providers.blank? providers.each do |name, configuration| - configuration.delete(:name) migrate_provider!(name, configuration) 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 859f3a34a01d..2c8df881e6c4 100644 --- a/modules/openid_connect/lib/open_project/openid_connect.rb +++ b/modules/openid_connect/lib/open_project/openid_connect.rb @@ -4,17 +4,36 @@ module OpenProject module OpenIDConnect - def providers + def self.configuration + providers = ::OpenIDConnect::Provider.where(available: true) + + OpenProject::Cache.fetch(providers.cache_key) do + providers.each_with_object({}) do |provider, hash| + hash[provider.slug.to_sym] = provider.to_h + end + end + end + + def self.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']}" ) - providers = ::OpenIDConnect::Provider.where(available: true).select(&:configured?) - configuration = providers.each_with_object({}) do |provider, hash| - hash[provider.slug] = provider.to_h + + configuration.map do |slug, configuration| + provider = configuration.delete(:oidc_provider) + clazz = + case provider + when "google" + ::OmniAuth::OpenIDConnect::Google + when "microsoft_entra" + ::OmniAuth::OpenIDConnect::Azure + else + ::OmniAuth::OpenIDConnect::Provider + end + + clazz.new(slug, configuration) end - ::OmniAuth::OpenIDConnect::Providers.load(configuration) end - module_function :providers end end From d0390a25243d5e27ef76fb73ad4cd5377ad3859f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 11:15:01 +0200 Subject: [PATCH 27/41] Skip metadata check for built-in otherwise they will appear incomplete, even though they are complete --- .../app/models/openid_connect/provider.rb | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index cd03b98f626e..14a35feeed55 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -41,15 +41,13 @@ def seeded_from_env? (Setting.seed_oidc_provider || {}).key?(slug) end - def basic_details_configured? - display_name.present? && (oidc_provider == "microsoft_entra" ? tenant.present? : true) - end - def advanced_details_configured? client_id.present? && client_secret.present? end def metadata_configured? + return true if google? || entra_id? + DISCOVERABLE_ATTRIBUTES_MANDATORY.all? do |mandatory_attribute| public_send(mandatory_attribute).present? end @@ -61,8 +59,16 @@ def mapping_configured? end end + def google? + oidc_provider == "google" + end + + def entra_id? + oidc_provider == "microsoft_entra" + end + def configured? - basic_details_configured? && advanced_details_configured? && metadata_configured? + display_name.present? && advanced_details_configured? && metadata_configured? end def icon From 46eb11ad2e539c978e224c229e5ac2c5f77f84d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 11:19:23 +0200 Subject: [PATCH 28/41] Remove unused providers helper --- .../openid_connect/providers_controller.rb | 16 ++++++---------- .../openid_connect/providers/index.html.erb | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) 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 c5ba074407e0..ab54f477fff7 100644 --- a/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb +++ b/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb @@ -10,7 +10,9 @@ class ProvidersController < ::ApplicationController before_action :find_provider, only: %i[edit update destroy] before_action :set_edit_state, only: %i[create edit update] - def index; end + def index + @providers = ::OpenIDConnect::Provider.all + end def new oidc_provider = case params[:oidc_provider] @@ -81,16 +83,10 @@ def check_ee end def find_provider - @provider = providers.where(id: params[:id]).first - if @provider.nil? - render_404 - end - end - - def providers - @providers ||= ::OpenIDConnect::Provider.where(available: true) + @provider = OpenIDConnect::Provider.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 end - helper_method :providers def default_breadcrumb; end 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 30c030c5613e..69ee277667dc 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 @@ -35,4 +35,4 @@ end %> -<%= render ::OpenIDConnect::Providers::TableComponent.new(rows: providers) %> +<%= render ::OpenIDConnect::Providers::TableComponent.new(rows: @providers) %> From d5987fa6c1b6f8eb98c56978695cd29932a3c23a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 11:38:55 +0200 Subject: [PATCH 29/41] Remove state lambda It is defined in the omnaiuth gem already (however with different bits) --- .../app/models/openid_connect/provider/hash_builder.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb b/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb index 5e59c0178956..388547cd5b07 100644 --- a/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb +++ b/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb @@ -32,8 +32,7 @@ def provider_specific_to_h when "google" { client_auth_method: :not_basic, - send_nonce: false, # use state instead of nonce - state: lambda { SecureRandom.hex(42) } + send_nonce: false } when "microsoft_entra" { From 0959dcadb3a8396c515dfb0955df20b4b3df0c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 11:39:02 +0200 Subject: [PATCH 30/41] Set default issuer --- .../openid_connect/providers/set_attributes_service.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index ec1879e64a01..e0b2cbec2417 100644 --- 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 @@ -31,8 +31,9 @@ module Providers class SetAttributesService < BaseServices::SetAttributes private - def set_default_attributes(*) + def set_default_attributes(*) # rubocop:disable Metrics/AbcSize model.change_by_system do + model.issuer ||= OpenProject::StaticRouting::StaticUrlHelpers.new.root_url model.creator ||= user model.slug ||= "#{model.class.slug_fragment}-#{model.display_name.to_url}" if model.display_name end From 15dcc7603aa4aeb75f8a2d1523bc8b0f287d5cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 14:16:47 +0200 Subject: [PATCH 31/41] Allow setting omniauth direct login provider to the new auth providers --- app/models/auth_provider.rb | 4 ++++ .../authentication_settings/show.html.erb | 21 +++++++++++++++++++ config/locales/en.yml | 10 +++++++++ modules/auth_saml/app/models/saml/provider.rb | 4 ++++ .../app/models/openid_connect/provider.rb | 4 ++++ 5 files changed, 43 insertions(+) diff --git a/app/models/auth_provider.rb b/app/models/auth_provider.rb index 69d724c0faed..769d63d65cc4 100644 --- a/app/models/auth_provider.rb +++ b/app/models/auth_provider.rb @@ -36,6 +36,10 @@ def self.slug_fragment raise NotImplementedError end + def human_type + raise NotImplementedError + end + def auth_url root_url = OpenProject::StaticRouting::StaticUrlHelpers.new.root_url URI.join(root_url, "auth/#{slug}/").to_s diff --git a/app/views/admin/settings/authentication_settings/show.html.erb b/app/views/admin/settings/authentication_settings/show.html.erb index 8943b42aee49..cca0e8c6d325 100644 --- a/app/views/admin/settings/authentication_settings/show.html.erb +++ b/app/views/admin/settings/authentication_settings/show.html.erb @@ -57,6 +57,27 @@ See COPYRIGHT and LICENSE files for more details. <%= render Settings::NumericSettingComponent.new("invitation_expiration_days", unit: "days") %> +
+ <%= I18n.t(:'settings.authentication.single_sign_on') %> +
+ <% providers = AuthProvider + .where(available: true) + .order("lower(display_name) ASC") + .select(:type, :display_name, :slug) + .to_a + .map { |p| ["#{p.display_name} (#{p.human_type})", p.slug] } + %> + <%= setting_select :omniauth_direct_login_provider, + [[t(:label_disabled), ""]] + providers, + container_class: '-middle' %> + + <%= t("settings.authentication.omniauth_direct_login_hint_html", + internal_path: internal_signin_url) %> + +
+
+ +