diff --git a/app/controllers/api/auth_providers_controller.rb b/app/controllers/api/auth_providers_controller.rb index 43616067b..379c2a315 100644 --- a/app/controllers/api/auth_providers_controller.rb +++ b/app/controllers/api/auth_providers_controller.rb @@ -108,7 +108,7 @@ class API::AuthProvidersController < API::APIController elsif params['auth_provider']['providable_type'] == SamlProvider.name params.require(:auth_provider) .permit(:id, :name, :providable_type, - providable_attributes: [:id, :sp_entity_id, :idp_sso_service_url], + providable_attributes: [:id, :sp_entity_id, :idp_sso_service_url, :profile_url, :idp_cert_fingerprint, :idp_cert], auth_provider_mappings_attributes: [:id, :local_model, :local_field, :api_field, :api_endpoint, :api_data_type, :_destroy, { transformation: [:type, :format, :true_value, :false_value, { mapping: %i[from to] }] }]) diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 2bcb14bb1..61f3d2906 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -2,6 +2,7 @@ # Handle authentication actions via OmniAuth (used by SSO providers) class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController + skip_before_action :verify_authenticity_token require 'sso_logger' logger = SsoLogger.new diff --git a/app/frontend/src/javascript/components/authentication-provider/data-mapping-form.tsx b/app/frontend/src/javascript/components/authentication-provider/data-mapping-form.tsx index 785819bfb..fe86a7456 100644 --- a/app/frontend/src/javascript/components/authentication-provider/data-mapping-form.tsx +++ b/app/frontend/src/javascript/components/authentication-provider/data-mapping-form.tsx @@ -12,6 +12,7 @@ import { TypeMappingModal } from './type-mapping-modal'; import { useImmer } from 'use-immer'; import { Oauth2DataMappingForm } from './oauth2-data-mapping-form'; import { OpenidConnectDataMappingForm } from './openid-connect-data-mapping-form'; +import { SamlDataMappingForm } from './saml-data-mapping-form'; export interface DataMappingFormProps { register: UseFormRegister, @@ -164,6 +165,11 @@ export const DataMappingForm = } + {providerType === 'SamlProvider' && }
diff --git a/app/frontend/src/javascript/components/authentication-provider/saml-data-mapping-form.tsx b/app/frontend/src/javascript/components/authentication-provider/saml-data-mapping-form.tsx new file mode 100644 index 000000000..7039b0880 --- /dev/null +++ b/app/frontend/src/javascript/components/authentication-provider/saml-data-mapping-form.tsx @@ -0,0 +1,87 @@ +import { Path, UseFormRegister } from 'react-hook-form'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FormInput } from '../form/form-input'; +import { HtmlTranslate } from '../base/html-translate'; +import { useTranslation } from 'react-i18next'; +import { UnpackNestedValue, UseFormSetValue, FormState } from 'react-hook-form/dist/types/form'; +import { FabButton } from '../base/fab-button'; +import { FieldPathValue } from 'react-hook-form/dist/types/path'; +import { AuthenticationProviderMapping } from '../../models/authentication-provider'; + +interface SamlDataMappingFormProps { + register: UseFormRegister, + setValue: UseFormSetValue, + currentFormValues: Array, + index: number, + formState: FormState +} + +/** + * Partial form to set the data mapping for an SAML provider. + * The data mapping is the way to bind data from the SAML to the Fab-manager's database + */ +export const SamlDataMappingForm = ({ register, setValue, currentFormValues, index, formState }: SamlDataMappingFormProps) => { + const { t } = useTranslation('admin'); + + const standardConfiguration = { + 'user.uid': { api_field: 'email' }, + 'user.email': { api_field: 'email' }, + 'user.username': { api_field: 'login' }, + 'profile.first_name': { api_field: 'firstName' }, + 'profile.last_name': { api_field: 'lastName' }, + 'profile.phone': { api_field: 'primaryPhone' }, + 'profile.address': { api_field: 'postalAddress' } + }; + + /** + * Set the data mapping according to the standard OpenID Connect specification + */ + const openIdStandardConfiguration = (): void => { + const model = currentFormValues[index]?.local_model; + const field = currentFormValues[index]?.local_field; + const configuration = standardConfiguration[`${model}.${field}`]; + if (configuration) { + setValue( + `auth_provider_mappings_attributes.${index}.api_field` as Path, + configuration.api_field as UnpackNestedValue>> + ); + if (configuration.transformation) { + Object.keys(configuration.transformation).forEach((key) => { + setValue( + `auth_provider_mappings_attributes.${index}.transformation.${key}` as Path, + configuration.transformation[key] as UnpackNestedValue>> + ); + }); + } + } + }; + + return ( +
+ + + } + label={t('app.admin.authentication.saml_data_mapping_form.api_field')} /> + } + className="auto-configure-button" + onClick={openIdStandardConfiguration} + tooltip={t('app.admin.authentication.saml_data_mapping_form.openid_standard_configuration')} /> +
+ ); +}; diff --git a/app/frontend/src/javascript/components/authentication-provider/saml-form.tsx b/app/frontend/src/javascript/components/authentication-provider/saml-form.tsx index 3c9df8565..5fdbd9221 100644 --- a/app/frontend/src/javascript/components/authentication-provider/saml-form.tsx +++ b/app/frontend/src/javascript/components/authentication-provider/saml-form.tsx @@ -30,14 +30,32 @@ export const SamlForm = ({ register, strategyN + + +
diff --git a/app/frontend/src/javascript/controllers/admin/authentications.js b/app/frontend/src/javascript/controllers/admin/authentications.js index 8dcab8592..55491bdf4 100644 --- a/app/frontend/src/javascript/controllers/admin/authentications.js +++ b/app/frontend/src/javascript/controllers/admin/authentications.js @@ -19,7 +19,8 @@ const METHODS = { DatabaseProvider: 'local_database', OAuth2Provider: 'o_auth2', - OpenIdConnectProvider: 'openid_connect' + OpenIdConnectProvider: 'openid_connect', + SamlProvider: 'saml' }; /** diff --git a/app/frontend/src/javascript/models/authentication-provider.ts b/app/frontend/src/javascript/models/authentication-provider.ts index 59700726a..a9e61cd09 100644 --- a/app/frontend/src/javascript/models/authentication-provider.ts +++ b/app/frontend/src/javascript/models/authentication-provider.ts @@ -69,6 +69,9 @@ export interface SamlProvider { id?: string, sp_entity_id: string, idp_sso_service_url: string + idp_cert_fingerprint: string, + idp_cert: string, + profile_url: string, } export interface MappingFields { diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 9d763ef92..3806e85c6 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -22,6 +22,7 @@ @import "modules/authentication-provider/array-mapping-form"; @import "modules/authentication-provider/data-mapping-form"; @import "modules/authentication-provider/openid-connect-data-mapping-form"; +@import "modules/authentication-provider/saml-data-mapping-form"; @import "modules/authentication-provider/provider-form"; @import "modules/authentication-provider/type-mapping-modal"; @import "modules/base/edit-destroy-buttons"; diff --git a/app/frontend/src/stylesheets/modules/authentication-provider/saml-data-mapping-form.scss b/app/frontend/src/stylesheets/modules/authentication-provider/saml-data-mapping-form.scss new file mode 100644 index 000000000..70e11dad2 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/authentication-provider/saml-data-mapping-form.scss @@ -0,0 +1,7 @@ +.saml-data-mapping-form { + .auto-configure-button { + align-self: center; + margin-top: 0.8rem; + margin-left: 20px; + } +} diff --git a/app/models/auth_provider.rb b/app/models/auth_provider.rb index 8b21c58ae..5628cfb00 100644 --- a/app/models/auth_provider.rb +++ b/app/models/auth_provider.rb @@ -17,7 +17,7 @@ class AuthProvider < ApplicationRecord end end - PROVIDABLE_TYPES = %w[DatabaseProvider OAuth2Provider OpenIdConnectProvider].freeze + PROVIDABLE_TYPES = %w[DatabaseProvider OAuth2Provider OpenIdConnectProvider SamlProvider].freeze belongs_to :providable, polymorphic: true, dependent: :destroy accepts_nested_attributes_for :providable @@ -27,7 +27,7 @@ class AuthProvider < ApplicationRecord validates :providable_type, inclusion: { in: PROVIDABLE_TYPES } validates :name, presence: true, uniqueness: true - validates_with UserUidMappedValidator, if: -> { %w[OAuth2Provider OpenIdConnectProvider].include?(providable_type) } + validates_with UserUidMappedValidator, if: -> { %w[OAuth2Provider OpenIdConnectProvider SamlProvider].include?(providable_type) } before_create :set_initial_state after_update :write_reload_config diff --git a/app/views/api/auth_providers/show.json.jbuilder b/app/views/api/auth_providers/show.json.jbuilder index ff0535c57..55531d9c6 100644 --- a/app/views/api/auth_providers/show.json.jbuilder +++ b/app/views/api/auth_providers/show.json.jbuilder @@ -22,6 +22,6 @@ end if @provider.providable_type == SamlProvider.name json.providable_attributes do - json.extract! @provider.providable, :id, :sp_entity_id, :idp_sso_service_url + json.extract! @provider.providable, :id, :sp_entity_id, :idp_sso_service_url, :profile_url, :idp_cert_fingerprint, :idp_cert end end diff --git a/app/views/auth_provider/provider.json.jbuilder b/app/views/auth_provider/provider.json.jbuilder index 30bcd2fef..efbfbdce8 100644 --- a/app/views/auth_provider/provider.json.jbuilder +++ b/app/views/auth_provider/provider.json.jbuilder @@ -20,3 +20,9 @@ if provider.providable_type == 'OpenIdConnectProvider' :extra_authorize_params end end + +if provider.providable_type == 'SamlProvider' + json.providable_attributes do + json.extract! provider.providable, :id, :sp_entity_id, :idp_sso_service_url, :profile_url, :idp_cert_fingerprint, :idp_cert + end +end diff --git a/config/environments/development.rb b/config/environments/development.rb index d2d22723f..93e33c9f4 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -91,6 +91,8 @@ Rails.application.configure do config.web_console.permissions = %w[192.168.0.0/16 192.168.99.0/16 10.0.2.2] config.hosts << ENV.fetch('DEFAULT_HOST', 'localhost') + config.hosts << "37abab1a904d96b727afdf86f2eb4830.serveo.net" + config.action_controller.forgery_protection_origin_check = false # https://github.com/flyerhzm/bullet # In development, Bullet will find and report N+1 DB requests diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 7a465d9f1..5b5b71555 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -248,8 +248,10 @@ Devise.setup do |config| when 'SamlProvider' require_relative '../../lib/omni_auth/saml' config.omniauth active_provider.strategy_name.to_sym, - active_provider.providable.sp_entity_id, - active_provider.providable.idp_sso_service_url, + sp_entity_id: active_provider.providable.sp_entity_id, + idp_sso_service_url: active_provider.providable.idp_sso_service_url, + idp_cert: active_provider.providable.idp_cert, + idp_cert_fingerprint: active_provider.providable.idp_cert_fingerprint, strategy_class: OmniAuth::Strategies::SsoSamlProvider end end diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 67341f678..57b6c3470 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1230,6 +1230,7 @@ en: local_database: "Local database" o_auth2: "OAuth 2.0" openid_connect: "OpenID Connect" + saml: "SAML" group_form: add_a_group: "Add a group" group_name: "Group name" @@ -1496,6 +1497,10 @@ en: api_field: "Userinfo claim" api_field_help_html: 'Set the field providing the corresponding data through the userinfo endpoint.
JsonPath syntax is supported. If many fields are selected, the first one will be used.
Example: $.data[*].name' openid_standard_configuration: "Use the OpenID standard configuration" + saml_data_mapping_form: + api_field: "Userinfo field" + api_field_help_html: "Set the field providing the corresponding data through the SAML assertion.
If many fields are selected, the first one will be used.
Example: $.data[*].name" + openid_standard_configuration: "Use the SAML standard configuration" type_mapping_modal: data_mapping: "Data mapping" TYPE_expected: "{TYPE} expected" @@ -1552,6 +1557,16 @@ en: client__end_session_endpoint_help: "The url to call to log the user out at the authorization server." extra_authorize_params: "Extra authorize parameters" extra_authorize_params_help: "A hash of extra fixed parameters that will be merged to the authorization request" + saml_form: + authorization_callback_url: "Authorization callback URL" + sp_entity_id: "Service provider entity ID" + sp_entity_id_help: "The name of your application. Some identity providers might need this to establish the identity of the service provider requesting the login." + idp_sso_service_url: "Identity provider SSO service URL" + idp_sso_service_url_help: "The URL to which the authentication request should be sent. This would be on the identity provider." + idp_cert_fingerprint: "Identity provider certificate fingerprint" + idp_cert: "Identity provider certificate" + profile_edition_url: "Profil edition URL" + profile_edition_url_help: "The URL of the page where the user can edit his profile." provider_form: name: "Name" authentication_type: "Authentication type" @@ -1562,6 +1577,7 @@ en: local_database: "Local database" oauth2: "OAuth 2.0" openid_connect: "OpenID Connect" + saml: "SAML" #create a new authentication provider (SSO) authentication_new: add_a_new_authentication_provider: "Add a new authentication provider" diff --git a/db/migrate/20240126145351_add_profile_url_to_saml_providers.rb b/db/migrate/20240126145351_add_profile_url_to_saml_providers.rb new file mode 100644 index 000000000..50d10bb4d --- /dev/null +++ b/db/migrate/20240126145351_add_profile_url_to_saml_providers.rb @@ -0,0 +1,7 @@ +# frozen_string_literal:true + +class AddProfileUrlToSamlProviders < ActiveRecord::Migration[7.0] + def change + add_column :saml_providers, :profile_url, :string +s end +end diff --git a/db/migrate/20240126192110_add_idp_cert_to_saml_provider.rb b/db/migrate/20240126192110_add_idp_cert_to_saml_provider.rb new file mode 100644 index 000000000..d6f5893c9 --- /dev/null +++ b/db/migrate/20240126192110_add_idp_cert_to_saml_provider.rb @@ -0,0 +1,6 @@ +class AddIdpCertToSamlProvider < ActiveRecord::Migration[7.0] + def change + add_column :saml_providers, :idp_cert, :string + add_column :saml_providers, :idp_cert_fingerprint, :string + end +end diff --git a/db/structure.sql b/db/structure.sql index 62f1c8dec..aad684c7c 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -3275,7 +3275,10 @@ CREATE TABLE public.saml_providers ( sp_entity_id character varying, idp_sso_service_url character varying, created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL + updated_at timestamp(6) without time zone NOT NULL, + profile_url character varying, + idp_cert character varying, + idp_cert_fingerprint character varying ); @@ -9319,6 +9322,8 @@ INSERT INTO "schema_migrations" (version) VALUES ('20230907124230'), ('20231103093436'), ('20231108094433'), -('20240116163703'); +('20240116163703'), +('20240126145351'), +('20240126192110'); diff --git a/lib/omni_auth/saml.rb b/lib/omni_auth/saml.rb index d63791c61..461749f30 100644 --- a/lib/omni_auth/saml.rb +++ b/lib/omni_auth/saml.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -require_relative 'strategies/saml_provider' +require_relative 'strategies/sso_saml_provider' diff --git a/lib/omni_auth/strategies/sso_saml_provider.rb b/lib/omni_auth/strategies/sso_saml_provider.rb index b8405a426..ab48aac0e 100644 --- a/lib/omni_auth/strategies/sso_saml_provider.rb +++ b/lib/omni_auth/strategies/sso_saml_provider.rb @@ -1,8 +1,34 @@ # frozen_string_literal: true require 'omniauth-saml' +require_relative '../data_mapping/mapper' # Authentication strategy provided trough SAML class OmniAuth::Strategies::SsoSamlProvider < OmniAuth::Strategies::SAML include OmniAuth::DataMapping::Mapper + + def self.active_provider + active_provider = Rails.configuration.auth_provider + if active_provider.providable_type != 'SamlProvider' + raise "Trying to instantiate the wrong provider: Expected SamlProvider, received #{active_provider.providable_type}" + end + + active_provider + end + + # Strategy name. + option :name, active_provider.strategy_name + + info do + { + mapping: parsed_info + } + end + + def parsed_info + mapped_info( + OmniAuth::Strategies::SsoSamlProvider.active_provider.auth_provider_mappings, + user_info: @attributes.attributes.transform_values {|v| v.is_a?(Array) ? v.first : v } + ) + end end