diff --git a/Makefile b/Makefile index 3f6e31bbc058..9fe880143426 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,27 @@ fe-up: up: make -j 2 be-up fe-up +# For testing different SSO methods +be-up-claveunica: + docker compose down + BASE_DEV_URI=https://claveunica-h2dkc.loca.lt docker compose up -d + lt --print-requests --port 3000 --subdomain claveunica-h2dkc + +be-up-nemlogin: + docker compose down + BASE_DEV_URI=https://nemlogin-k3kd.loca.lt docker compose up -d + lt --print-requests --port 3000 --subdomain nemlogin-k3kd + +be-up-idaustria: + docker compose down + BASE_DEV_URI=https://idaustria-g3fy.loca.lt docker compose up -d + lt --print-requests --port 3000 --subdomain idaustria-g3fy + +be-up-keycloak: + docker compose down + BASE_DEV_URI=https://keycloak-r3tyu.loca.lt docker compose up -d + lt --print-requests --port 3000 --subdomain keycloak-r3tyu + # Run it with: # make c # # or diff --git a/back/Gemfile b/back/Gemfile index 9220513e1d7b..0293df1d6ccb 100644 --- a/back/Gemfile +++ b/back/Gemfile @@ -233,6 +233,7 @@ commercial_engines = [ 'id_franceconnect', 'id_gent_rrn', 'id_id_card_lookup', + 'id_keycloak', 'id_nemlog_in', 'id_oostende_rrn', # Some engines actually register an authentication method rather diff --git a/back/Gemfile.lock b/back/Gemfile.lock index def06bcd4642..fb74f7259b60 100644 --- a/back/Gemfile.lock +++ b/back/Gemfile.lock @@ -155,6 +155,14 @@ PATH savon (>= 2.12, < 2.15) verification +PATH + remote: engines/commercial/id_keycloak + specs: + id_keycloak (0.1.0) + omniauth_openid_connect (~> 0.7.1) + rails (~> 7.0) + verification + PATH remote: engines/commercial/id_nemlog_in specs: @@ -1288,6 +1296,7 @@ DEPENDENCIES id_gent_rrn! id_hoplr! id_id_card_lookup! + id_keycloak! id_nemlog_in! id_oostende_rrn! id_vienna_saml! diff --git a/back/config/initializers/omniauth.rb b/back/config/initializers/omniauth.rb index 0ad922d12a61..c49b9e90e609 100644 --- a/back/config/initializers/omniauth.rb +++ b/back/config/initializers/omniauth.rb @@ -5,7 +5,9 @@ end # See https://github.com/omniauth/omniauth/wiki/Resolving-CVE-2015-9284 +# TODO: Change all implementations to use POST requests OmniAuth.config.allowed_request_methods = %i[post get] +OmniAuth.config.silence_get_warning = true OmniAuth.config.full_host = lambda { |_env| AppConfiguration.instance&.base_backend_uri diff --git a/back/engines/commercial/id_keycloak/app/lib/id_keycloak/feature_specification.rb b/back/engines/commercial/id_keycloak/app/lib/id_keycloak/feature_specification.rb new file mode 100644 index 000000000000..d0cd0464ac6a --- /dev/null +++ b/back/engines/commercial/id_keycloak/app/lib/id_keycloak/feature_specification.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'citizen_lab/mixins/feature_specification' + +module IdKeycloak + module FeatureSpecification + extend CitizenLab::Mixins::FeatureSpecification + + def self.feature_name + 'keycloak_login' + end + + def self.feature_title + 'Keycloak (ID-Porten) Login' + end + + def self.feature_description + 'Allow users to authenticate with a Norwegian ID-Porten (via Keycloak) account.' + end + + def self.allowed_by_default + false + end + + def self.enabled_by_default + false + end + end +end diff --git a/back/engines/commercial/id_keycloak/app/lib/id_keycloak/keycloak_omniauth.rb b/back/engines/commercial/id_keycloak/app/lib/id_keycloak/keycloak_omniauth.rb new file mode 100644 index 000000000000..f9f1627475f0 --- /dev/null +++ b/back/engines/commercial/id_keycloak/app/lib/id_keycloak/keycloak_omniauth.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module IdKeycloak + class KeycloakOmniauth < OmniauthMethods::Base + include KeycloakVerification + + def profile_to_user_attrs(auth) + { + first_name: auth.info.first_name, + last_name: auth.info.last_name, + email: auth.info.email, + locale: AppConfiguration.instance.closest_locale_to('nb-NO') # No need to get the locale from the provider + } + end + + # @param [AppConfiguration] configuration + def omniauth_setup(configuration, env) + return unless Verification::VerificationService.new.active?(configuration, name) + + options = env['omniauth.strategy'].options + + options[:scope] = %i[openid] + options[:response_type] = :code + options[:issuer] = issuer + options[:client_options] = { + identifier: config[:client_id], + secret: config[:client_secret], + redirect_uri: "#{configuration.base_backend_uri}/auth/keycloak/callback", + + # NOTE: Cannot use auto discovery as .well-known/openid-configuration is not on the root of the domain + client_signing_alg: :RS256, + authorization_endpoint: "#{issuer}/protocol/openid-connect/auth", + token_endpoint: "#{issuer}/protocol/openid-connect/token", + introspection_endpoint: "#{issuer}/protocol/openid-connect/token/introspect", + userinfo_endpoint: "#{issuer}/protocol/openid-connect/userinfo", + jwks_uri: "#{issuer}/protocol/openid-connect/certs" + } + end + + def email_always_present? + false + end + + def verification_prioritized? + true + end + + def email_confirmed?(auth) + # Response will tell us if the email is verified + auth&.info&.email_verified + end + + def filter_auth_to_persist(auth) + auth_to_persist = auth.deep_dup + auth_to_persist.tap { |h| h.delete(:credentials) } + end + + def issuer + "https://#{config[:domain]}/auth/realms/idporten" + end + + def updateable_user_attrs + super + %i[first_name last_name] + end + end +end diff --git a/back/engines/commercial/id_keycloak/app/lib/id_keycloak/keycloak_verification.rb b/back/engines/commercial/id_keycloak/app/lib/id_keycloak/keycloak_verification.rb new file mode 100644 index 000000000000..82a931d9fa79 --- /dev/null +++ b/back/engines/commercial/id_keycloak/app/lib/id_keycloak/keycloak_verification.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module IdKeycloak + module KeycloakVerification + include Verification::VerificationMethod + + def verification_method_type + :omniauth + end + + def id + 'd6938fe6-4bee-4490-b80c-b14dafb5da1b' + end + + def name + 'keycloak' + end + + def config_parameters + %i[ + ui_method_name + domain + client_id + client_secret + ] + end + + def config_parameters_schema + { + ui_method_name: { + type: 'string', + description: 'The name this verification method will have in the UI', + default: 'ID-Porten' + } + } + end + + def exposed_config_parameters + [ + :ui_method_name + ] + end + + def locked_attributes + %i[first_name last_name] + end + + def other_attributes + %i[email] + end + + def profile_to_uid(auth) + auth['uid'] + end + + def updateable_user_attrs + super + %i[first_name last_name] + end + end +end diff --git a/back/engines/commercial/id_keycloak/config/initializers/omniauth.rb b/back/engines/commercial/id_keycloak/config/initializers/omniauth.rb new file mode 100644 index 000000000000..87d78adc0b19 --- /dev/null +++ b/back/engines/commercial/id_keycloak/config/initializers/omniauth.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +KEYCLOAK_SETUP_PROC = lambda do |env| + IdKeycloak::KeycloakOmniauth.new.omniauth_setup(AppConfiguration.instance, env) +end + +Rails.application.config.middleware.use OmniAuth::Builder do + provider :openid_connect, setup: KEYCLOAK_SETUP_PROC, name: 'keycloak', issuer: IdKeycloak::KeycloakOmniauth.new.method(:issuer) +end diff --git a/back/engines/commercial/id_keycloak/id_keycloak.gemspec b/back/engines/commercial/id_keycloak/id_keycloak.gemspec new file mode 100644 index 000000000000..f6dcb487bc7b --- /dev/null +++ b/back/engines/commercial/id_keycloak/id_keycloak.gemspec @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +$LOAD_PATH.push File.expand_path('lib', __dir__) + +# Maintain your gem's version: +require 'id_keycloak/version' + +# Describe your gem and declare its dependencies: +Gem::Specification.new do |s| + s.name = 'id_keycloak' + s.version = IdKeycloak::VERSION + s.summary = 'Verification using Keycloak (ID Porten)' + s.authors = ['Go Vocal'] + s.licenses = [Gem::Licenses::NONSTANDARD] # ['Go Vocal Commercial License V2'] + s.files = Dir['{app,config,db,lib}/**/*', 'Rakefile', 'README.md'] + + s.add_dependency 'rails', '~> 7.0' + s.add_dependency 'verification' + s.add_dependency 'omniauth_openid_connect', '~> 0.7.1' + + s.add_development_dependency 'rspec_api_documentation' + s.add_development_dependency 'rspec-rails' +end diff --git a/back/engines/commercial/id_keycloak/lib/id_keycloak.rb b/back/engines/commercial/id_keycloak/lib/id_keycloak.rb new file mode 100644 index 000000000000..aa741beda017 --- /dev/null +++ b/back/engines/commercial/id_keycloak/lib/id_keycloak.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'id_keycloak/engine' + +module IdKeycloak + # Your code goes here... +end diff --git a/back/engines/commercial/id_keycloak/lib/id_keycloak/engine.rb b/back/engines/commercial/id_keycloak/lib/id_keycloak/engine.rb new file mode 100644 index 000000000000..81ff2b08c70e --- /dev/null +++ b/back/engines/commercial/id_keycloak/lib/id_keycloak/engine.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module IdKeycloak + class Engine < ::Rails::Engine + isolate_namespace IdKeycloak + + config.to_prepare do + AppConfiguration::Settings.add_feature(IdKeycloak::FeatureSpecification) + + keycloak = KeycloakOmniauth.new + Verification.add_method(keycloak) + AuthenticationService.add_method('keycloak', keycloak) + end + end +end diff --git a/back/engines/commercial/id_keycloak/lib/id_keycloak/version.rb b/back/engines/commercial/id_keycloak/lib/id_keycloak/version.rb new file mode 100644 index 000000000000..88cd745e8a20 --- /dev/null +++ b/back/engines/commercial/id_keycloak/lib/id_keycloak/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module IdKeycloak + VERSION = '0.1.0' +end diff --git a/back/engines/commercial/id_keycloak/spec/requests/keycloak_verification_spec.rb b/back/engines/commercial/id_keycloak/spec/requests/keycloak_verification_spec.rb new file mode 100644 index 000000000000..2775cf74ca08 --- /dev/null +++ b/back/engines/commercial/id_keycloak/spec/requests/keycloak_verification_spec.rb @@ -0,0 +1,340 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'rspec_api_documentation/dsl' + +context 'keycloak verification (ID-Porten - Oslo)' do + let(:auth_hash) do + { + 'provider' => 'keycloak', + 'uid' => 'b045a9a9-cf7e-4add-acc7-1f606eb1e9e0', + 'info' => { + 'name' => 'UNØYAKTIG KOST', + 'email' => 'test@govocal.com', + 'email_verified' => false, + 'nickname' => '21929974805', + 'first_name' => 'UNØYAKTIG', + 'last_name' => 'KOST', + 'gender' => nil, + 'image' => nil, + 'phone' => '+447780122122', + 'urls' => { 'website' => nil } + }, + 'credentials' => { + # rubocop:disable Layout/LineLength + 'id_token' => + 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJMQW1ZSGI5ODZGWnAwX29pT2NaaTFzUUxVaU1TeFFsYkYtdl9OQmdHQ2J3In0.eyJleHAiOjE3MjgzMDcyNzAsImlhdCI6MTcyODMwNjk3MCwiYXV0aF90aW1lIjoxNzI4MzA1MjQ4LCJqdGkiOiI4NjQ2NDA5My05YzRiLTRlMTItOThjOC1iNTM0ZTg0MzZjMzUiLCJpc3MiOiJodHRwczovL2xvZ2luLXRlc3Qub3Nsby5rb21tdW5lLm5vL2F1dGgvcmVhbG1zL2lkcG9ydGVuIiwiYXVkIjoibWVkdmlya25pbmciLCJzdWIiOiJiMDQ1YTlhOS1jZjdlLTRhZGQtYWNjNy0xZjYwNmViMWU5ZTAiLCJ0eXAiOiJJRCIsImF6cCI6Im1lZHZpcmtuaW5nIiwibm9uY2UiOiIyODRmZjQ2NTcxYzIwMTVjZWQxNzY1NjQ0Mjc3ZDk0NCIsInNlc3Npb25fc3RhdGUiOiI0ZTEyMjQ4YS0yNGZlLTQ2Y2YtYWY5Mi0wNWNmYjNlNzllMzEiLCJhdF9oYXNoIjoibzVXV1NPNTBvS2xRMXdsLV9KdUZXUSIsImFjciI6IjAiLCJzaWQiOiI0ZTEyMjQ4YS0yNGZlLTQ2Y2YtYWY5Mi0wNWNmYjNlNzllMzEiLCJhZGRyZXNzIjp7fSwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJhY3Jfc2VjdXJpdHlfbGV2ZWwiOiJpZHBvcnRlbi1sb2Etc3Vic3RhbnRpYWwiLCJhbXIiOiJUZXN0SUQiLCJuYW1lIjoiVU7DmFlBS1RJRyBLT1NUIiwicGlkIjoiMjE5Mjk5NzQ4MDUiLCJwaG9uZV9udW1iZXIiOiIrNDQ3Nzg3MTM1MzYxIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiMjE5Mjk5NzQ4MDUiLCJnaXZlbl9uYW1lIjoiVU7DmFlBS1RJRyIsImxvY2FsZSI6ImVuIiwiZmFtaWx5X25hbWUiOiJLT1NUIiwiZW1haWwiOiJ0ZXN0QHNwZWFrZS5vcmcifQ.M2DclHfBvELc8mSD0Av-9WJ0k0Py4SAQb3TsaldPdwaGGo57Jb-L0-RJ18eaeGTPtThNXWCZ-1aVd_Wf97Dq_rZUGarlD5OWXVn6DVuNSkRkv_s-a7vKOHw7bxz-eQ-yQIdYE47u0FvBGFB5SdHGdtCVE7hqKT1CWVXHtPYC1r5DV80YlHijhyZPHicjrnq4qBDaXmQepa_CBPjYz-jhwyaYFnEHEpRS_SaP3TpXA4DV3pgapigftpxtiMzgEZ75aHRrjnq_sBtXdffCNZcKCjO9ZM3OXmO8PYqQSORNBXBJBoZ4XaBNZr75s4LVgF3tigjGSeoJncaNm93zmCPQ_Q', + 'token' => + 'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJMQW1ZSGI5ODZGWnAwX29pT2NaaTFzUUxVaU1TeFFsYkYtdl9OQmdHQ2J3In0.eyJleHAiOjE3MjgzMDcyNzAsImlhdCI6MTcyODMwNjk3MCwiYXV0aF90aW1lIjoxNzI4MzA1MjQ4LCJqdGkiOiJmYjY4Zjc2Yi1kYTU3LTQ1ZDItOWU0Ny0zNzIxNDIzNDBiNDQiLCJpc3MiOiJodHRwczovL2xvZ2luLXRlc3Qub3Nsby5rb21tdW5lLm5vL2F1dGgvcmVhbG1zL2lkcG9ydGVuIiwiYXVkIjpbImJyb2tlciIsImFjY291bnQiXSwic3ViIjoiYjA0NWE5YTktY2Y3ZS00YWRkLWFjYzctMWY2MDZlYjFlOWUwIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoibWVkdmlya25pbmciLCJub25jZSI6IjI4NGZmNDY1NzFjMjAxNWNlZDE3NjU2NDQyNzdkOTQ0Iiwic2Vzc2lvbl9zdGF0ZSI6IjRlMTIyNDhhLTI0ZmUtNDZjZi1hZjkyLTA1Y2ZiM2U3OWUzMSIsImFjciI6IjAiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cHM6Ly9rZXljbG9hay5lcGljLmNpdGl6ZW5sYWIuY28iLCJodHRwczovL21lZHZpcmtuaW5nLm9zbG8ua29tbXVuZS5ubyIsImh0dHBzOi8va2V5Y2xvYWstcjN0eXUubG9jYS5sdCIsImh0dHBzOi8vZGVtby5zdGcuZ292b2NhbC5jb20iXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtaWRwb3J0ZW4iLCJvZmZsaW5lX2FjY2VzcyIsIkxldmVsMyIsInVzZXIiXX0sInJlc291cmNlX2FjY2VzcyI6eyJicm9rZXIiOnsicm9sZXMiOlsicmVhZC10b2tlbiJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJwcm9maWxlIGVtYWlsIG9wZW5pZCBvc2xva29tbXVuZS1vcGVuaWRjb25uZWN0LW1pbmltYWwgYWRkcmVzcyIsInNpZCI6IjRlMTIyNDhhLTI0ZmUtNDZjZi1hZjkyLTA1Y2ZiM2U3OWUzMSIsImFkZHJlc3MiOnt9LCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJVTsOYWUFLVElHIEtPU1QiLCJsb2NhbGUiOiJlbiIsImdpdmVuX25hbWUiOiJVTsOYWUFLVElHIiwiZmFtaWx5X25hbWUiOiJLT1NUIiwiZW1haWwiOiJ0ZXN0QHNwZWFrZS5vcmcifQ.FHdrszn8_CnZ-AF8e2UsModph3AwjNv8QSzDHIuTHdroG6DJLRKPZYgywQZ7W9RYWtRvckikZvNepT0WxWp-OjPOIMH7zavy0XWfT7E15lD_0xx8dDBoyMr6JxyXRL4Pb7fSxqiW27W3cQjQSC_c_iHWCJhgLukDndBQu43Rq7l-uLWrIwJiLzSGdUG1jKynHhBcYIxQXnJapoWAPXfhC9RytZPBoZg9D_G5KXMnLIb2_Q4mu2Oqlkmk2YZxWVkwG9bMiX3stanY88_ieY2g9L1jJXq_V6GvjzpBpeKT1qLx40ES-ojQaNeVOxlv1dX5kBRDvm_iIlA7wCu660i5jw', + 'refresh_token' => + 'eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJjNTUxN2U2Mi04NmU3LTQ2YjgtOWFhZi04NGM4YWYyNWU1ZDQifQ.eyJleHAiOjE3MjgzMDg3NzAsImlhdCI6MTcyODMwNjk3MCwianRpIjoiMTBkMGI5OGUtNzYzNi00ZmE3LWE2OWMtY2MxY2FhODlhZWQ0IiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi10ZXN0Lm9zbG8ua29tbXVuZS5uby9hdXRoL3JlYWxtcy9pZHBvcnRlbiIsImF1ZCI6Imh0dHBzOi8vbG9naW4tdGVzdC5vc2xvLmtvbW11bmUubm8vYXV0aC9yZWFsbXMvaWRwb3J0ZW4iLCJzdWIiOiJiMDQ1YTlhOS1jZjdlLTRhZGQtYWNjNy0xZjYwNmViMWU5ZTAiLCJ0eXAiOiJSZWZyZXNoIiwiYXpwIjoibWVkdmlya25pbmciLCJub25jZSI6IjI4NGZmNDY1NzFjMjAxNWNlZDE3NjU2NDQyNzdkOTQ0Iiwic2Vzc2lvbl9zdGF0ZSI6IjRlMTIyNDhhLTI0ZmUtNDZjZi1hZjkyLTA1Y2ZiM2U3OWUzMSIsInNjb3BlIjoicHJvZmlsZSBlbWFpbCBvcGVuaWQgb3Nsb2tvbW11bmUtb3BlbmlkY29ubmVjdC1taW5pbWFsIGFkZHJlc3MiLCJzaWQiOiI0ZTEyMjQ4YS0yNGZlLTQ2Y2YtYWY5Mi0wNWNmYjNlNzllMzEifQ.O017EugYMgsXZ9m3yn96UjFeu90qTD4pmtQ8OYCKmRY', + 'expires_in' => 300, + 'scope' => 'profile email openid oslokommune-openidconnect-minimal address' + # rubocop:enable Layout/LineLength + }, + 'extra' => { + 'raw_info' => { + 'sub' => 'b045a9a9-cf7e-4add-acc7-1f606eb1e9e0', + 'address' => {}, + 'email_verified' => false, + 'amr' => 'TestID', + 'pid' => '21929974805', + 'preferred_username' => '21929974805', + 'given_name' => 'UNØYAKTIG', + 'locale' => 'en', + 'acr_security_level' => 'idporten-loa-substantial', + 'name' => 'UNØYAKTIG KOST', + 'phone_number' => '+447780122122', + 'family_name' => 'KOST', + 'email' => 'test@govocal.com', + 'exp' => 1_728_307_270, + 'iat' => 1_728_306_970, + 'auth_time' => 1_728_305_248, + 'jti' => '86464093-9c4b-4e12-98c8-b534e8436c35', + 'iss' => 'https://login-test.oslo.kommune.no/auth/realms/idporten', + 'aud' => 'medvirkning', + 'typ' => 'ID', + 'azp' => 'medvirkning', + 'nonce' => '284ff46571c2015ced1765644277d944', + 'session_state' => '4e12248a-24fe-46cf-af92-05cfb3e79e31', + 'at_hash' => 'o5WWSO50oKlQ1wl-_JuFWQ', + 'acr' => '0', + 'sid' => '4e12248a-24fe-46cf-af92-05cfb3e79e31' + } + } + } + end + + before do + @user = create(:user, first_name: 'EXISTING', last_name: 'USER') + @token = AuthToken::AuthToken.new(payload: @user.to_token_payload).token + + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:keycloak] = OmniAuth::AuthHash.new(auth_hash) + + configuration = AppConfiguration.instance + settings = configuration.settings + settings['verification'] = { + allowed: true, + enabled: true, + verification_methods: [{ + name: 'keycloak', + domain: 'some.test.domain.com', + client_id: '12345', + client_secret: '78910' + }] + } + configuration.save! + host! 'example.org' + end + + def expect_user_to_be_verified(user) + expect(user.reload).to have_attributes({ + verified: true, + first_name: 'UNØYAKTIG', + last_name: 'KOST', + custom_field_values: {} + }) + expect(user.verifications.first).to have_attributes({ + method_name: 'keycloak', + user_id: user.id, + active: true, + hashed_uid: Verification::VerificationService.new.send(:hashed_uid, auth_hash['uid'], 'keycloak') + }) + end + + def expect_user_to_be_verified_and_identified(user) + expect_user_to_be_verified(user) + expect(user.identities.first).to have_attributes({ + provider: 'keycloak', + user_id: user.id, + uid: auth_hash['uid'] + }) + expect(user.identities.first.auth_hash['credentials']).not_to be_present + expect(user.identities.first.auth_hash.keys).to eq %w[uid info extra provider] + end + + context 'email provided in auth response' do + it 'successfully verifies an existing user' do + get "/auth/keycloak?token=#{@token}&random-passthrough-param=somevalue&pathname=/yipie" + follow_redirect! + + expect_user_to_be_verified(@user) + + expect(response).to redirect_to('/en/yipie?random-passthrough-param=somevalue&verification_success=true') + end + + it 'successfully authenticates an existing user that was previously verified' do + get "/auth/keycloak?token=#{@token}" + follow_redirect! + + expect(User.count).to eq(1) + expect(@user.identities.count).to eq(0) + expect_user_to_be_verified(@user) + + get '/auth/keycloak' + follow_redirect! + + expect(User.count).to eq(1) + expect(@user.identities.count).to eq(1) + expect_user_to_be_verified_and_identified(@user) + end + + it 'successfully verifies another user with another ID-Porten account' do + get "/auth/keycloak?token=#{@token}" + follow_redirect! + expect(@user.reload).to have_attributes({ + verified: true + }) + + user2 = create(:user) + token2 = AuthToken::AuthToken.new(payload: user2.to_token_payload).token + auth_hash['uid'] = 'f8f151a4-4a80-4106-919c-244084e1ce21' + OmniAuth.config.mock_auth[:keycloak] = OmniAuth::AuthHash.new(auth_hash) + + get "/auth/keycloak?token=#{token2}" + follow_redirect! + expect(user2.reload).to have_attributes(verified: true) + end + + it 'fails when uid has already been used' do + create( + :verification, + method_name: 'keycloak', + hashed_uid: Verification::VerificationService.new.send(:hashed_uid, auth_hash['uid'], 'keycloak') + ) + + get "/auth/keycloak?token=#{@token}" + follow_redirect! + + expect(@user.reload).to have_attributes(verified: false) + end + + it 'creates a new user when the authentication token is not passed' do + expect(User.count).to eq(1) + get '/auth/keycloak?param=some-param' + follow_redirect! + + expect(User.count).to eq(2) + + user = User.order(created_at: :asc).last + expect_user_to_be_verified_and_identified(user) + + expect(user).not_to eq(@user) + expect(user).to have_attributes({ + email: 'test@govocal.com', + password_digest: nil + }) + + expect(response).to redirect_to('/en/?param=some-param') + end + end + + context 'email NOT provided in auth response' do + before do + configuration = AppConfiguration.instance + configuration.settings['password_login'] = { + 'allowed' => true, + 'enabled' => true, + 'enable_signup' => true, + 'minimum_length' => 8 + } + configuration.save! + auth_hash['info']['email'] = nil + OmniAuth.config.mock_auth[:keycloak] = OmniAuth::AuthHash.new(auth_hash) + end + + context 'when verification is already taken by a new user with no email' do + before do + get '/auth/keycloak' + follow_redirect! + end + + let!(:new_user) do + User.order(created_at: :asc).last.tap do |user| + expect(user).to have_attributes({ email: nil }) + expect_user_to_be_verified_and_identified(user) + end + end + + context 'when verified registration is completed by new user' do + before { new_user.update!(email: Faker::Internet.email) } + + it 'does not verify another user and does not delete previously verified new user' do + get "/auth/keycloak?token=#{@token}&pathname=/some-page" + follow_redirect! + + expect(response).to redirect_to('/some-page?verification_error=true&error_code=taken') + expect(@user.reload).to have_attributes({ + verified: false, + first_name: 'EXISTING', + last_name: 'USER' + }) + + expect(new_user.reload).to eq(new_user) + end + end + + context 'when verified registration is not completed by new user' do + it 'successfully verifies another user and deletes previously verified blank new user' do + get "/auth/keycloak?token=#{@token}&pathname=/some-page" + follow_redirect! + + expect(response).to redirect_to('/en/some-page?verification_success=true') + expect_user_to_be_verified(@user.reload) + expect { new_user.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + context 'email confirmation enabled' do + before do + configuration = AppConfiguration.instance + configuration.settings['user_confirmation'] = { + 'enabled' => true, + 'allowed' => true + } + configuration.save! + end + + it 'creates user that can add & confirm her email' do + get '/auth/keycloak' + follow_redirect! + + user = User.order(created_at: :asc).last + expect_user_to_be_verified_and_identified(user) + expect(user.email).to be_nil + expect(user.active?).to be(true) + expect(user.confirmation_required?).to be(false) + expect(ActionMailer::Base.deliveries.count).to eq(0) + + headers = { 'Authorization' => authorization_header(user) } + + patch "/web_api/v1/users/#{user.id}", params: { user: { email: 'newcoolemail@example.org' } }, headers: headers + expect(response).to have_http_status(:ok) + expect(user.reload).to have_attributes({ email: 'newcoolemail@example.org' }) + expect(user.confirmation_required?).to be(true) + expect(user.active?).to be(false) + expect(ActionMailer::Base.deliveries.count).to eq(1) + + post '/web_api/v1/user/confirm', params: { confirmation: { code: user.email_confirmation_code } }, headers: headers + expect(response).to have_http_status(:ok) + expect(user.reload.confirmation_required?).to be(false) + expect(user.active?).to be(true) + expect(user).to have_attributes({ email: 'newcoolemail@example.org' }) + expect(user.new_email).to be_nil + end + + it 'allows users to be active without adding an email & confirmation' do + get '/auth/keycloak' + follow_redirect! + + get '/auth/keycloak' + follow_redirect! + + user = User.order(created_at: :asc).last + expect_user_to_be_verified_and_identified(user) + expect(user.email).to be_nil + expect(user.confirmation_required?).to be(false) + expect(user.active?).to be(true) + end + + it 'does not send email to empty email address (when just registered)' do + get '/auth/keycloak' + follow_redirect! + + expect(ActionMailer::Base.deliveries).to be_empty + end + end + + context 'email confirmation disabled' do + before do + configuration = AppConfiguration.instance + configuration.settings['user_confirmation'] = { + 'enabled' => false, + 'allowed' => false + } + configuration.save! + + create(:custom_field, key: 'birthdate') + create(:custom_field, key: 'birthyear', input_type: 'number') + create(:custom_field, key: 'municipality_code') + end + + it 'creates user that can update her email' do + get '/auth/keycloak' + follow_redirect! + + user = User.order(created_at: :asc).last + expect_user_to_be_verified_and_identified(user) + + token = AuthToken::AuthToken.new(payload: user.to_token_payload).token + headers = { 'Authorization' => "Bearer #{token}" } + patch "/web_api/v1/users/#{user.id}", params: { user: { email: 'newcoolemail@example.org' } }, headers: headers + expect(response).to have_http_status(:ok) + expect(user.reload).to have_attributes({ email: 'newcoolemail@example.org' }) + expect(user.confirmation_required?).to be(false) + end + end + end +end diff --git a/docker-compose.yml b/docker-compose.yml index 52c56c6aebcf..bc3ae07ff64f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: postgres: # When you update PostgreSQL, make sure to upgrade the `postgresql-client` version @@ -36,7 +34,8 @@ services: - "env_files/back-safe.env" - "env_files/back-secret.env" environment: - BUNDLE_PATH: /bundle + - BUNDLE_PATH=/bundle + - BASE_DEV_URI tty: true stdin_open: true diff --git a/front/app/api/app_configuration/types.ts b/front/app/api/app_configuration/types.ts index 03a2138d0908..a0a832a13bf8 100644 --- a/front/app/api/app_configuration/types.ts +++ b/front/app/api/app_configuration/types.ts @@ -131,6 +131,10 @@ export interface IAppConfigurationSettings { allowed: boolean; enabled: boolean; }; + keycloak_login?: { + allowed: boolean; + enabled: boolean; + }; nemlog_in_login?: { allowed: boolean; enabled: boolean; diff --git a/front/app/api/authentication/singleSignOn.ts b/front/app/api/authentication/singleSignOn.ts index ba0a467caef5..d872e85a2e71 100644 --- a/front/app/api/authentication/singleSignOn.ts +++ b/front/app/api/authentication/singleSignOn.ts @@ -20,6 +20,7 @@ export interface SSOProviderMap { criipto: 'criipto'; fake_sso: 'fake_sso'; nemlog_in: 'nemlog_in'; + keycloak: 'keycloak'; } export type SSOProvider = SSOProviderMap[keyof SSOProviderMap]; diff --git a/front/app/api/verification_methods/types.ts b/front/app/api/verification_methods/types.ts index 7448e028f423..6aee5cd90bae 100644 --- a/front/app/api/verification_methods/types.ts +++ b/front/app/api/verification_methods/types.ts @@ -13,6 +13,7 @@ export const verificationTypesLeavingPlatform = [ 'clave_unica', 'franceconnect', 'nemlog_in', + 'keycloak', ]; export type TVerificationMethodName = @@ -25,6 +26,7 @@ export type TVerificationMethodName = | 'franceconnect' | 'gent_rrn' | 'id_card_lookup' + | 'keycloak' | 'nemlog_in' | 'oostende_rrn'; @@ -87,6 +89,16 @@ export type IDCriiptoMethod = { }; }; +export type IDKeycloakMethod = { + id: string; + type: 'verification_method'; + attributes: { + name: 'keycloak'; + method_metadata?: MethodMetadata; + ui_method_name: string; + }; +}; + export type IDAuth0Method = { id: string; type: 'verification_method'; @@ -102,4 +114,5 @@ export type TVerificationMethod = | FakeSSOMethod | IDLookupMethod | IDCriiptoMethod + | IDKeycloakMethod | IDAuth0Method; diff --git a/front/app/component-library/components/Icon/index.tsx b/front/app/component-library/components/Icon/index.tsx index b26b6d2caaec..75767a52ee96 100644 --- a/front/app/component-library/components/Icon/index.tsx +++ b/front/app/component-library/components/Icon/index.tsx @@ -410,6 +410,21 @@ export const icons = { /> ), + idporten: (props: IconPropsWithoutName) => ( + + + + + + + + + + + ), comment: (props: IconPropsWithoutName) => ( ( const { pathname } = useLocation(); const tenantSettings = tenant?.data.attributes.settings; + // Allows testing of specific SSO providers without showing to all users eg ?provider=keycloak + const [searchParams] = useSearchParams(); + const providerForTest = searchParams.get('provider'); + + // A hidden path that will show all methods inc any that are admin only const showAdminOnlyMethods = pathname.endsWith('/sign-in/admin'); const passwordLoginEnabled = useFeatureFlag({ name: 'password_login' }); @@ -87,6 +92,10 @@ const AuthProviders = memo( const nemlogInLoginEnabled = useFeatureFlag({ name: 'nemlog_in_login', }); + const keycloakLoginEnabled = + useFeatureFlag({ + name: 'keycloak_login', + }) || providerForTest === 'keycloak'; const azureProviderName = tenantSettings?.azure_ad_login?.login_mechanism_name; @@ -127,6 +136,7 @@ const AuthProviders = memo( claveUnicaLoginEnabled || hoplrLoginEnabled || criiptoLoginEnabled || + keycloakLoginEnabled || nemlogInLoginEnabled; return ( @@ -206,6 +216,22 @@ const AuthProviders = memo( )} + {keycloakLoginEnabled && ( + + + + )} + { const { formatMessage } = useIntl(); + // TODO: JS - This code is repeated in other places const passwordLoginEnabled = useFeatureFlag({ name: 'password_login' }); const googleLoginEnabled = useFeatureFlag({ name: 'google_login' }); const facebookLoginEnabled = useFeatureFlag({ name: 'facebook_login' }); @@ -38,6 +39,9 @@ const SSOButtons = (props: Props) => { const criiptoLoginEnabled = useFeatureFlag({ name: 'criipto_login', }); + const keycloakLoginEnabled = useFeatureFlag({ + name: 'keycloak_login', + }); if ( !googleLoginEnabled && @@ -47,7 +51,8 @@ const SSOButtons = (props: Props) => { !franceconnectLoginEnabled && !claveUnicaLoginEnabled && !hoplrLoginEnabled && - !criiptoLoginEnabled + !criiptoLoginEnabled && + !keycloakLoginEnabled ) { if (passwordLoginEnabled) { return null; diff --git a/front/app/containers/Authentication/useAnySSOEnabled/index.tsx b/front/app/containers/Authentication/useAnySSOEnabled/index.tsx index 16cdf23d8dbf..a45dfe47badd 100644 --- a/front/app/containers/Authentication/useAnySSOEnabled/index.tsx +++ b/front/app/containers/Authentication/useAnySSOEnabled/index.tsx @@ -25,6 +25,10 @@ export default function useAnySSOEnabled() { name: 'criipto_login', }); + const keycloakLoginEnabled = useFeatureFlag({ + name: 'keycloak_login', + }); + const anySSOEnabled = fakeSSOEnabled || googleLoginEnabled || @@ -35,7 +39,8 @@ export default function useAnySSOEnabled() { viennaCitizenLoginEnabled || claveUnicaLoginEnabled || hoplrLoginEnabled || - criiptoLoginEnabled; + criiptoLoginEnabled || + keycloakLoginEnabled; return anySSOEnabled; } diff --git a/front/app/modules/commercial/id_keycloak/components/KeycloakButton.tsx b/front/app/modules/commercial/id_keycloak/components/KeycloakButton.tsx new file mode 100644 index 000000000000..864d4973dc22 --- /dev/null +++ b/front/app/modules/commercial/id_keycloak/components/KeycloakButton.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { + TVerificationMethod, + IDKeycloakMethod, +} from 'api/verification_methods/types'; + +import { AUTH_PATH } from 'containers/App/constants'; + +import VerificationMethodButton from 'components/UI/VerificationMethodButton'; + +import { getJwt } from 'utils/auth/jwt'; +import { removeUrlLocale } from 'utils/removeUrlLocale'; + +interface Props { + onClick: (method: TVerificationMethod) => void; + verificationMethod: IDKeycloakMethod; + last: boolean; +} + +const KeycloakButton = ({ onClick, verificationMethod, last }: Props) => { + const handleOnClick = () => { + onClick(verificationMethod); + const jwt = getJwt(); + window.location.href = `${AUTH_PATH}/keycloak?token=${jwt}&pathname=${removeUrlLocale( + window.location.pathname + )}`; + }; + + return ( + + {verificationMethod.attributes.ui_method_name} + + ); +}; + +export default KeycloakButton; diff --git a/front/app/modules/commercial/id_keycloak/index.tsx b/front/app/modules/commercial/id_keycloak/index.tsx new file mode 100644 index 000000000000..fe70baf77fc6 --- /dev/null +++ b/front/app/modules/commercial/id_keycloak/index.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { + IDKeycloakMethod, + TVerificationMethodName, +} from 'api/verification_methods/types'; +import { isLastVerificationMethod } from 'api/verification_methods/util'; + +import { ModuleConfiguration } from 'utils/moduleUtils'; + +import KeycloakButton from './components/KeycloakButton'; + +const verificationMethodName: TVerificationMethodName = 'keycloak'; +const configuration: ModuleConfiguration = { + outlets: { + 'app.components.VerificationModal.buttons': ({ + verificationMethods, + ...props + }) => { + const method = verificationMethods.find( + (vm) => vm.attributes.name === verificationMethodName + ); + + if (method) { + const last = isLastVerificationMethod( + verificationMethodName, + verificationMethods + ); + return ( + + ); + } + + return null; + }, + }, +}; + +export default configuration; diff --git a/front/app/modules/index.ts b/front/app/modules/index.ts index a406da5651bd..5735274d401d 100644 --- a/front/app/modules/index.ts +++ b/front/app/modules/index.ts @@ -18,6 +18,7 @@ import idBosaFasConfiguration from './commercial/id_bosa_fas'; import IdClaveUnicaConfiguration from './commercial/id_clave_unica'; import idCowConfiguration from './commercial/id_cow'; import idCriiptoConfiguration from './commercial/id_criipto'; +import idKeycloakConfiguration from './commercial/id_keycloak'; import IdFranceConnectConfiguration from './commercial/id_franceconnect'; import IdGentRrnConfiguration from './commercial/id_gent_rrn'; import idIdCardLookupConfiguration from './commercial/id_id_card_lookup'; @@ -104,6 +105,9 @@ export default loadModules([ { configuration: idCriiptoConfiguration, }, + { + configuration: idKeycloakConfiguration, + }, { configuration: idBogusConfiguration, },