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) => (