diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index b431507334eb0..95bb617bb4f1b 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -21,7 +21,9 @@
"dynamicfront",
"frontend",
"incron",
- "minion"
+ "minion",
+ "redis-listener",
+ "keycloak"
],
"workspaceFolder": "/opt/product-opener",
"customizations": {
diff --git a/.dockerignore b/.dockerignore
index 114ac74dc2483..3c51a569d58f1 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,4 +1,5 @@
node_modules
+deps/
.devcontainer
.git
.github
diff --git a/.env b/.env
index 722f745b2c63f..9c7fb782bf138 100644
--- a/.env
+++ b/.env
@@ -69,6 +69,14 @@ ODOO_CRM_DB=
ODOO_CRM_USER=
ODOO_CRM_PASSWORD=
+KEYCLOAK_BASE_URL=http://auth.openfoodfacts.localhost:5600
+KEYCLOAK_BACKCHANNEL_BASE_URL=http://keycloak:8080
+KEYCLOAK_REALM_NAME=open-products-facts
+
+PRODUCT_OPENER_OIDC_CLIENT_ID=ProductOpener
+PRODUCT_OPENER_OIDC_CLIENT_SECRET=Cf4NdSAjZsNO9HLcuXeuvukzFu00roQa
+PRODUCT_OPENER_OIDC_DISCOVERY_ENDPOINT=http://auth.openfoodfacts.localhost:5600/realms/open-products-facts/.well-known/openid-configuration
+
BUILD_CACHE_REPO=openfoodfacts/openfoodfacts-build-cache
# If you want the rate limiter to block requests (return 429) instead of doing nothing,
diff --git a/.gitignore b/.gitignore
index e9d5f0fdf1270..135c61097ba0e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -66,7 +66,6 @@ nytprof*.out
# Local databases
data/mongodb
Lang.open*
-users_emails.sto
html/data/*
html/products_countries.js
products_stats_*.html
diff --git a/Dockerfile b/Dockerfile
index 5202fc15e2d72..54de1a87ce54c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -53,7 +53,6 @@ RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt set -x && \
libcache-memcached-fast-perl \
libjson-pp-perl \
libclone-perl \
- libcrypt-passwdmd5-perl \
libencode-detect-perl \
libgraphics-color-perl \
libbarcode-zbar-perl \
@@ -67,6 +66,7 @@ RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt set -x && \
libdbd-pg-perl \
libtemplate-perl \
liburi-escape-xs-perl \
+ libanyevent-redis-perl \
# NB: not available in ubuntu 1804 LTS:
libmath-random-secure-perl \
libfile-copy-recursive-perl \
@@ -158,6 +158,8 @@ RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt set -x && \
libperl-dev \
# needed to build Apache2::Connection::XForwardedFor
libapache2-mod-perl2-dev \
+ # OpenSSL dev needed by OIDC::Lite
+ libssl-dev \
# Imager::zxing - build deps
cmake \
pkg-config \
diff --git a/Makefile b/Makefile
index c33cf08bfe7dd..f57b43e9e8425 100644
--- a/Makefile
+++ b/Makefile
@@ -54,13 +54,15 @@ DOCKER_COMPOSE_RUN=COMPOSE_FILE="${COMPOSE_FILE};docker/run.yml" ${DOCKER_COMPOS
# keep web-default for web contents
# we also publish mongodb on a separate port to avoid conflicts
# we also enable the possibility to fake services in po_test_runner
-DOCKER_COMPOSE_TEST=WEB_RESOURCES_PATH=./web-default ROBOTOFF_URL="http://backend:8881/" GOOGLE_CLOUD_VISION_API_URL="http://backend:8881/" COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}_test COMPOSE_FILE="${COMPOSE_FILE};${DEPS_DIR}/openfoodfacts-shared-services/docker-compose.yml" PO_COMMON_PREFIX=test_ MONGO_EXPOSE_PORT=27027 MONGODB_CACHE_SIZE=4 ODOO_CRM_URL= docker compose --env-file=${ENV_FILE}
-# Enable Redis only for integration tests
-DOCKER_COMPOSE_INT_TEST=REDIS_URL="redis:6379" ${DOCKER_COMPOSE_TEST}
+DOCKER_COMPOSE_TEST_BASE=WEB_RESOURCES_PATH=./web-default ROBOTOFF_URL="http://backend:8881/" GOOGLE_CLOUD_VISION_API_URL="http://backend:8881/" COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}_test PO_COMMON_PREFIX=test_ MONGODB_CACHE_SIZE=4 ODOO_CRM_URL= docker compose --env-file=${ENV_FILE}
+DOCKER_COMPOSE_TEST=COMPOSE_FILE="${COMPOSE_FILE};${DEPS_DIR}/openfoodfacts-shared-services/docker-compose.yml" ${DOCKER_COMPOSE_TEST_BASE}
+# Enable Redis only for integration tests. TODO: Currently using dev tag for keycloak - need to switch to main
+DOCKER_COMPOSE_INT_TEST=COMPOSE_FILE="${COMPOSE_FILE};docker/integration-test.yml" REDIS_URL="redis:6379" KEYCLOAK_BASE_URL=http://keycloak:8080 PRODUCT_OPENER_OIDC_DISCOVERY_ENDPOINT=http://keycloak:8080/realms/open-products-facts/.well-known/openid-configuration KEYCLOAK_ADMIN=test KEYCLOAK_ADMIN_PASSWORD=test ${DOCKER_COMPOSE_TEST_BASE}
+
TEST_CMD ?= yath test -PProductOpener::LoadData
# Space delimited list of dependant projects
-DEPS=openfoodfacts-shared-services
+DEPS=openfoodfacts-shared-services openfoodfacts-auth
# Set the DEPS_DIR if it hasn't been set already
ifeq (${DEPS_DIR},)
export DEPS_DIR=${PWD}/deps
@@ -128,7 +130,7 @@ build:
@echo "🥫 Building containers …"
${DOCKER_COMPOSE} build ${args} ${container} 2>&1
-_up:run_deps
+_up: run_deps
@echo "🥫 Starting containers …"
${DOCKER_COMPOSE_RUN} up -d 2>&1
@echo "🥫 started service at http://openfoodfacts.localhost"
@@ -142,7 +144,7 @@ prod_up: build create_folders
down:
@echo "🥫 Bringing down containers …"
- ${DOCKER_COMPOSE} down
+ ${DOCKER_COMPOSE} down --remove-orphans
hdown:
@echo "🥫 Bringing down containers and associated volumes …"
@@ -264,20 +266,14 @@ unit_test: create_folders
integration_test: create_folders
@echo "🥫 Running integration tests …"
-# we launch the server and run tests within same container
-# we also need dynamicfront for some assets to exists
+# we launch the server and run tests within same container. Dependendies are listed in integration-test.yml
# this is the place where variables are important
- ${DOCKER_COMPOSE_INT_TEST} up -d memcached postgres mongodb backend dynamicfront incron minion redis
+ ${DOCKER_COMPOSE_INT_TEST} up -d backend frontend
# note: we need the -T option for ci (non tty environment)
${DOCKER_COMPOSE_INT_TEST} exec ${COVER_OPTS} -e PO_EAGER_LOAD_DATA=1 -T backend yath -PProductOpener::LoadData tests/integration
${DOCKER_COMPOSE_INT_TEST} stop
@echo "🥫 integration tests success"
-# stop all tests dockers
-test-stop:
- @echo "🥫 Stopping test dockers"
- ${DOCKER_COMPOSE_TEST} stop
-
# usage: make test-unit test=test-name.t
# you can use TEST_CMD to change test command, like TEST_CMD="perl -d" to debug a test
# you can also add args= to pass more options to your test command
@@ -292,14 +288,14 @@ test-unit: guard-test create_folders
# you can also add args= to pass more options to your test command
test-int: guard-test create_folders
@echo "🥫 Running test: 'tests/integration/${test}' …"
- ${DOCKER_COMPOSE_INT_TEST} up -d memcached postgres mongodb backend dynamicfront incron minion redis
+ ${DOCKER_COMPOSE_INT_TEST} up -d backend frontend
${DOCKER_COMPOSE_INT_TEST} exec -e PO_EAGER_LOAD_DATA=1 backend ${TEST_CMD} ${args} tests/integration/${test}
# better shutdown, for if we do a modification of the code, we need a restart
- ${DOCKER_COMPOSE_INT_TEST} stop backend
+ ${DOCKER_COMPOSE_INT_TEST} stop
# stop all docker tests containers
stop_tests:
- ${DOCKER_COMPOSE_TEST} stop
+ ${DOCKER_COMPOSE_INT_TEST} stop
# clean tests, remove containers and volume (useful if you changed env variables, etc.)
clean_tests:
@@ -307,11 +303,9 @@ clean_tests:
update_tests_results: build_taxonomies_test build_lang_test
@echo "🥫 Updated expected test results with actuals for easy Git diff"
- ${DOCKER_COMPOSE_TEST} up -d memcached postgres mongodb backend dynamicfront incron
- ${DOCKER_COMPOSE_TEST} run --no-deps --rm -e GITHUB_TOKEN=${GITHUB_TOKEN} backend /opt/product-opener/scripts/taxonomies/build_tags_taxonomy.pl ${name}
- ${DOCKER_COMPOSE_TEST} run --rm backend perl -I/opt/product-opener/lib -I/opt/perl/local/lib/perl5 /opt/product-opener/scripts/build_lang.pl
- ${DOCKER_COMPOSE_TEST} exec -T -w /opt/product-opener/tests backend bash update_tests_results.sh
- ${DOCKER_COMPOSE_TEST} stop
+ ${DOCKER_COMPOSE_INT_TEST} up -d backend frontend
+ ${DOCKER_COMPOSE_INT_TEST} exec -T -w /opt/product-opener/tests backend bash update_tests_results.sh
+ ${DOCKER_COMPOSE_INT_TEST} stop
bash:
@echo "🥫 Open a bash shell in the backend container"
@@ -513,4 +507,3 @@ guard-%: # guard clause for targets that require an environment variable (usuall
echo "Environment variable '$*' is not set"; \
exit 1; \
fi;
-
diff --git a/cgi/auth.pl b/cgi/auth.pl
index 2b9ff9e7b947c..9faf35500b880 100755
--- a/cgi/auth.pl
+++ b/cgi/auth.pl
@@ -31,6 +31,7 @@
use ProductOpener::Users qw/$User_id %User is_admin_user/;
use ProductOpener::Lang qw/:all/;
use ProductOpener::Tags qw/country_to_cc/;
+use ProductOpener::Auth qw/write_auth_deprecated_headers/;
use Apache2::Const -compile => qw(OK);
use CGI qw/:cgi :form escapeHTML/;
diff --git a/cgi/change_password.pl b/cgi/change_password.pl
index 3b98cee36903a..b172c19150fa6 100644
--- a/cgi/change_password.pl
+++ b/cgi/change_password.pl
@@ -3,7 +3,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -25,78 +25,17 @@
use CGI::Carp qw(fatalsToBrowser);
use ProductOpener::Config qw/:all/;
-use ProductOpener::Paths qw/:all/;
-use ProductOpener::Store qw/:all/;
-use ProductOpener::Display qw/$tt display_page init_request process_template single_param/;
-use ProductOpener::Users qw/$User_id check_password_hash create_password_hash retrieve_user store_user/;
-use ProductOpener::Lang qw/lang/;
+use ProductOpener::Display qw/init_request display_error_and_exit redirect_to_url/;
-use Apache2::Const -compile => qw(OK);
-use CGI qw/:cgi :form escapeHTML/;
-use URI::Escape::XS;
-use Encode;
-use Log::Any qw($log);
+use URI::Escape::XS qw/uri_escape/;
my $request_ref = ProductOpener::Display::init_request();
-my $template_data_ref = {method => $ENV{'REQUEST_METHOD'}};
-
-$log->info('start') if $log->is_info();
-if (not defined $User_id) {
- my $r = shift;
- $r->headers_out->set(Location => '/cgi/login.pl?redirect=/cgi/change_password.pl');
- $r->status(307);
- return Apache2::Const::OK;
+unless ((defined $oidc_options{keycloak_base_url}) and (defined $oidc_options{keycloak_realm_name})) {
+ display_error_and_exit($request_ref, 'File not found.', 404);
}
-my @errors = ();
-
-if ($ENV{'REQUEST_METHOD'} eq 'POST') {
- # TODO: This will change for Keycloak
- my $user_ref = retrieve_user($User_id);
- if (not(defined $user_ref)) {
- push @errors, 'undefined user';
- $template_data_ref->{success} = 0;
- }
-
- my $hash_is_correct = check_password_hash(encode_utf8(decode utf8 => single_param('current_password')),
- $user_ref->{'encrypted_password'});
-
- # We don't have the right password
- if (not $hash_is_correct) {
- $log->info(
- 'bad password - input does not match stored hash',
- {encrypted_password => $user_ref->{'encrypted_password'}}
- ) if $log->is_info();
- push @errors, lang('error_bad_login_password');
- }
-
- if (length(single_param('password')) < 6) {
- push @errors, lang('error_invalid_password');
- }
-
- if ((single_param('password')) ne (single_param('confirm_password'))) {
- push @errors, lang('error_different_passwords');
- }
-
- if (scalar(@errors) > 0) {
- $template_data_ref->{success} = 0;
- }
- else {
- $user_ref->{encrypted_password} = create_password_hash(encode_utf8(decode utf8 => single_param('password')));
- store_user($user_ref);
- $template_data_ref->{success} = 1;
- }
-}
-
-$template_data_ref->{errors} = \@errors;
-
-my $html;
-process_template('web/pages/change_password/change_password.tt.html', $template_data_ref, \$html) or $html = '';
-if ($tt->error()) {
- $html .= '
' . $tt->error() . '
';
-}
+my $redirect
+ = $oidc_options{keycloak_base_url} . '/admin/realms/' . uri_escape($oidc_options{keycloak_realm_name}) . '/users';
-$request_ref->{title} = lang('change_password');
-$request_ref->{content_ref} = \$html;
-display_page($request_ref);
+redirect_to_url($request_ref, 302, $redirect);
diff --git a/cgi/login.pl b/cgi/login.pl
index ba991afe01d50..be582c6e7a4bd 100644
--- a/cgi/login.pl
+++ b/cgi/login.pl
@@ -3,7 +3,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -28,10 +28,11 @@
use ProductOpener::Paths qw/:all/;
use ProductOpener::Store qw/:all/;
use ProductOpener::Display qw/:all/;
-use ProductOpener::Users qw/$User_id check_password_hash retrieve_user/;
+use ProductOpener::Users qw/$User_id retrieve_user/;
use ProductOpener::Lang qw/lang/;
+use ProductOpener::Auth qw/password_signin access_to_protected_resource/;
-use Apache2::Const -compile => qw(OK);
+use Apache2::Const -compile => qw/OK :http/;
use CGI qw/:cgi :form escapeHTML/;
use URI::Escape::XS;
use Encode;
@@ -39,60 +40,43 @@
my $request_ref = ProductOpener::Display::init_request();
-my $template_data_ref = {};
-
$log->info('start') if $log->is_info();
my $r = shift;
my $redirect = single_param('redirect');
-$template_data_ref->{redirect} = $redirect;
+my $loc = $redirect || $formatted_subdomain . "/cgi/session.pl";
+my $status_code = Apache2::Const::HTTP_BAD_REQUEST;
+my $final_status_set = 0;
if (defined $User_id) {
- my $loc = $redirect || $formatted_subdomain . "/cgi/session.pl";
+ # User is already signed in via cookie or similar, as determined by init_request.
$r->headers_out->set(Location => $loc);
- $r->err_headers_out->add('Set-Cookie' => $request_ref->{cookie});
- $r->status(302);
- return Apache2::Const::OK;
+ $status_code = Apache2::Const::HTTP_MOVED_TEMPORARILY;
+ $final_status_set = 1;
}
-my @errors = ();
-
-if ($ENV{'REQUEST_METHOD'} eq 'POST') {
- my $user_ref = retrieve_user($User_id);
- if (not(defined $user_ref)) {
- push @errors, 'undefined user';
- $template_data_ref->{success} = 0;
- }
-
- my $hash_is_correct
- = check_password_hash(encode_utf8(decode utf8 => single_param('password')), $user_ref->{'encrypted_password'});
-
- # We don't have the right password
- if (not $hash_is_correct) {
- $log->info(
- 'bad password - input does not match stored hash',
- {encrypted_password => $user_ref->{'encrypted_password'}}
- ) if $log->is_info();
- push @errors, lang('error_bad_login_password');
- }
+if (not($final_status_set) and (not($ENV{'REQUEST_METHOD'} eq 'POST'))) {
+ # After OIDC/Keycloak integration, the original login form is no longer used.
+ # However, some external sites (ie. Hunger Games) may still be using it.
+ $request_ref->{return_url} = single_param('redirect');
+ access_to_protected_resource($request_ref);
+ $final_status_set = 1;
+}
- if (scalar(@errors) > 0) {
- $template_data_ref->{success} = 0;
+if (not($final_status_set)) {
+ my ($oidc_user_id, $refresh_token, $refresh_expires_at, $access_token, $access_expires_at, $id_token)
+ = password_signin(encode_utf8(decode utf8 => single_param('user_id')),
+ encode_utf8(decode utf8 => single_param('password')), $request_ref);
+ if ($oidc_user_id) {
+ $r->headers_out->set(Location => $loc);
+ $status_code = Apache2::Const::HTTP_MOVED_TEMPORARILY;
}
else {
- $template_data_ref->{success} = 1;
+ $status_code = Apache2::Const::HTTP_UNAUTHORIZED;
}
-}
-$template_data_ref->{errors} = \@errors;
-
-# Display the sign in form
-my $html;
-process_template('web/pages/session/sign_in_form.tt.html', $template_data_ref, \$html) or $html = '';
-if ($tt->error()) {
- $html .= '
' . $tt->error() . '
';
+ $final_status_set = 1;
}
-$request_ref->{title} = lang('login_register_title');
-$request_ref->{content_ref} = \$html;
-display_page($request_ref);
-
+$r->err_headers_out->add('Set-Cookie' => $request_ref->{cookie});
+$r->status($status_code);
+return Apache2::Const::OK;
diff --git a/scripts/reset_password_for_user.pl b/cgi/oidc_signin.pl
old mode 100755
new mode 100644
similarity index 54%
rename from scripts/reset_password_for_user.pl
rename to cgi/oidc_signin.pl
index 9a5deb800075e..1b3e6daf2eaac
--- a/scripts/reset_password_for_user.pl
+++ b/cgi/oidc_signin.pl
@@ -3,7 +3,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -20,27 +20,30 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-use Modern::Perl '2017';
-use utf8;
+=head1 DESCRIPTION
+
+This cgi script initiates sign-in process with the OIDC service (eg. keycloak)
+
+It redirects to the OIDC service, which will redirect back to oidc_signin_callback.pl
+
+=cut
+
+use ProductOpener::PerlStandards;
use CGI::Carp qw(fatalsToBrowser);
-use ProductOpener::Config qw/:all/;
-use ProductOpener::Paths qw/:all/;
-use ProductOpener::Store qw/:all/;
-use ProductOpener::Index qw/:all/;
-use ProductOpener::Display qw/:all/;
-use ProductOpener::Images qw/:all/;
-use ProductOpener::Users qw/create_password_hash retrieve_user store_user/;
-use ProductOpener::Mail qw/:all/;
-use ProductOpener::Lang qw/:all/;
-
-use CGI qw/:cgi :form escapeHTML/;
-use URI::Escape::XS;
-use Encode;
-
-my $userid = $ARGV[0];
-# This will need to be fixed for Keycloak
-my $user_ref = retrieve_user($userid);
-$user_ref->{encrypted_password} = create_password_hash(encode_utf8(decode utf8 => $ARGV[1]));
-store_user($user_ref);
+use ProductOpener::Auth qw/access_to_protected_resource/;
+use ProductOpener::Display qw/init_request single_param/;
+use ProductOpener::Routing qw/analyze_request/;
+
+use Log::Any qw($log);
+
+$log->info('start') if $log->is_info();
+
+my $request_ref = init_request();
+analyze_request($request_ref);
+
+$request_ref->{return_url} = single_param('return_url');
+access_to_protected_resource($request_ref);
+
+1;
diff --git a/cgi/oidc_signin_callback.pl b/cgi/oidc_signin_callback.pl
new file mode 100644
index 0000000000000..6b785d6d41e11
--- /dev/null
+++ b/cgi/oidc_signin_callback.pl
@@ -0,0 +1,60 @@
+#!/usr/bin/perl -w
+
+# This file is part of Product Opener.
+#
+# Product Opener
+# Copyright (C) 2011-2024 Association Open Food Facts
+# Contact: contact@openfoodfacts.org
+# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
+#
+# Product Opener is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+=head1 DESCRIPTION
+
+This cgi script is called after successful sign-in happens on OIDC service (eg. keycloak)
+
+It verifies authentication is ok, and redirects to a site url.
+
+=cut
+
+use ProductOpener::PerlStandards;
+
+use CGI::Carp qw(fatalsToBrowser);
+
+use ProductOpener::Auth qw/signin_callback/;
+use ProductOpener::Display qw/init_request display_error_and_exit redirect_to_url/;
+use ProductOpener::Routing qw/analyze_request/;
+use ProductOpener::URL qw/format_subdomain/;
+use ProductOpener::Users qw/$User_id/;
+
+use Log::Any qw($log);
+
+$log->info('start') if $log->is_info();
+
+my $request_ref = init_request();
+analyze_request($request_ref);
+
+my $return_url = signin_callback($request_ref);
+
+unless (defined $User_id) {
+ display_error_and_exit($request_ref, 'Unauthorized', 401);
+}
+
+unless (defined $return_url) {
+ $return_url = format_subdomain('world');
+}
+
+redirect_to_url($request_ref, 302, $return_url);
+
+1;
diff --git a/cgi/oidc_signout.pl b/cgi/oidc_signout.pl
new file mode 100644
index 0000000000000..b9d47fd6c9d76
--- /dev/null
+++ b/cgi/oidc_signout.pl
@@ -0,0 +1,53 @@
+#!/usr/bin/perl -w
+
+# This file is part of Product Opener.
+#
+# Product Opener
+# Copyright (C) 2011-2024 Association Open Food Facts
+# Contact: contact@openfoodfacts.org
+# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
+#
+# Product Opener is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+=head1 DESCRIPTION
+
+This cgi script initiate the sign-out process using OIDC service (eg. keycloak)
+
+It redirects to the correct OIDC service page.
+
+=cut
+
+use ProductOpener::PerlStandards;
+
+use CGI::Carp qw(fatalsToBrowser);
+
+use ProductOpener::Auth qw/start_signout/;
+use ProductOpener::Display qw/init_request display_error_and_exit/;
+use ProductOpener::Routing qw/analyze_request/;
+use ProductOpener::URL qw/format_subdomain/;
+
+use Log::Any qw($log);
+
+$log->info('start') if $log->is_info();
+
+my $request_ref = init_request();
+if (not($ENV{'REQUEST_METHOD'} eq 'POST')) {
+ display_error_and_exit($request_ref, 'Method Not Allowed.', 405);
+}
+
+analyze_request($request_ref);
+
+start_signout($request_ref);
+
+1;
diff --git a/cgi/oidc_signout_callback.pl b/cgi/oidc_signout_callback.pl
new file mode 100644
index 0000000000000..3035fcc1b52af
--- /dev/null
+++ b/cgi/oidc_signout_callback.pl
@@ -0,0 +1,54 @@
+#!/usr/bin/perl -w
+
+# This file is part of Product Opener.
+#
+# Product Opener
+# Copyright (C) 2011-2024 Association Open Food Facts
+# Contact: contact@openfoodfacts.org
+# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
+#
+# Product Opener is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+=head1 DESCRIPTION
+
+This cgi script is called after successful sign-out happens on OIDC service (eg. keycloak)
+
+It redirects to a sensible url.
+
+=cut
+
+use ProductOpener::PerlStandards;
+
+use CGI::Carp qw(fatalsToBrowser);
+
+use ProductOpener::Auth qw/signout_callback/;
+use ProductOpener::Display qw/init_request redirect_to_url/;
+use ProductOpener::Routing qw/analyze_request/;
+use ProductOpener::URL qw/format_subdomain/;
+
+use Log::Any qw($log);
+
+$log->info('start') if $log->is_info();
+
+my $request_ref = init_request();
+analyze_request($request_ref);
+
+my $return_url = signout_callback($request_ref);
+unless (defined $return_url) {
+ $return_url = format_subdomain('world');
+}
+
+redirect_to_url($request_ref, 302, $return_url);
+
+1;
diff --git a/cgi/product_multilingual.pl b/cgi/product_multilingual.pl
index 9c8b9f80281db..2f9b66aadc265 100755
--- a/cgi/product_multilingual.pl
+++ b/cgi/product_multilingual.pl
@@ -3,7 +3,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -24,6 +24,7 @@
use CGI::Carp qw(fatalsToBrowser);
+use ProductOpener::Auth qw/access_to_protected_resource/;
use ProductOpener::Config qw/:all/;
use ProductOpener::Paths qw/%BASE_DIRS/;
use ProductOpener::Store qw/get_string_id_for_lang/;
@@ -349,7 +350,7 @@ ($product_ref)
display_error_and_exit($request_ref, $Lang{error_no_permission}{$lc}, 403);
}
-if ($User_id eq 'unwanted-bot-id') {
+if ((defined $User_id) and ($User_id eq 'unwanted-bot-id')) {
my $r = Apache2::RequestUtil->request();
$r->status(500);
return 500;
@@ -359,9 +360,10 @@ ($product_ref)
if (not defined $User_id) {
- my $submit_label = "login_and_" . $type . "_product";
- $action = 'login';
- $template_data_ref->{type} = $type;
+ $request_ref->{return_url}
+ = $formatted_subdomain . $request_ref->{script_name} . '?' . $request_ref->{original_query_string};
+ # Note: This su will either finish without a result if a good user/token is present, or redirect to the login page and stop the script
+ access_to_protected_resource($request_ref);
}
}
diff --git a/cgi/reset_password.pl b/cgi/reset_password.pl
deleted file mode 100755
index 1cfe1a418c451..0000000000000
--- a/cgi/reset_password.pl
+++ /dev/null
@@ -1,188 +0,0 @@
-#!/usr/bin/perl -w
-
-# This file is part of Product Opener.
-#
-# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
-# Contact: contact@openfoodfacts.org
-# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
-#
-# Product Opener is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see .
-
-use ProductOpener::PerlStandards;
-
-use CGI::Carp qw(fatalsToBrowser);
-
-use ProductOpener::Config qw/:all/;
-use ProductOpener::Paths qw/:all/;
-use ProductOpener::Store qw/:all/;
-use ProductOpener::Index qw/:all/;
-use ProductOpener::Display qw/:all/;
-use ProductOpener::Images qw/:all/;
-use ProductOpener::Users qw/:all/;
-use ProductOpener::Mail qw/send_email/;
-use ProductOpener::Lang qw/$lc %Lang lang/;
-use ProductOpener::URL qw/format_subdomain/;
-
-use CGI qw/:cgi :form escapeHTML/;
-use URI::Escape::XS;
-use Encode;
-use Log::Any qw($log);
-
-my $request_ref = ProductOpener::Display::init_request();
-
-my $template_data_ref = {lang => \&lang,};
-
-my $type = single_param('type') || 'send_email';
-my $action = single_param('action') || 'display';
-
-my $id = single_param('userid_or_email');
-my $resetid = single_param('resetid');
-
-$log->info("start", {type => $type, action => $action, userid_or_email => $id, resetid => $resetid}) if $log->is_info();
-
-my @errors = ();
-
-my $user_ref = undef;
-
-my $html = '';
-
-if (defined $User_id) {
- display_error_and_exit($request_ref, $Lang{error_reset_already_connected}{$lc}, undef);
-}
-
-if ($action eq 'process') {
-
- if ($type eq 'send_email') {
-
- # Is it an email?
-
- if ($id =~ /\@/) {
- $user_ref = retrieve_user_by_email($id);
- if (not defined $user_ref) {
- push @errors, $Lang{error_reset_unknown_email}{$lc};
- }
- }
- else {
- $user_ref = retrieve_user($id);
- if (not defined $user_ref) {
- push @errors, $Lang{error_reset_unknown_id}{$lc};
- }
- }
-
- }
- elsif (($type eq 'reset') and (defined single_param('resetid'))) {
-
- if (length(single_param('password')) < 6) {
- push @errors, $Lang{error_invalid_password}{$lc};
- }
-
- if (single_param('password') ne single_param('confirm_password')) {
- push @errors, $Lang{error_different_passwords}{$lc};
- }
-
- }
- else {
- $log->debug("invalid address", {type => $type}) if $log->is_debug();
- display_error_and_exit($request_ref, lang("error_invalid_address"), 404);
- }
-
- if ($#errors >= 0) {
- $log->debug("errors", {errors => \@errors}) if $log->is_debug();
- $action = 'display';
- }
-}
-
-$template_data_ref->{action} = $action;
-$template_data_ref->{type} = $type;
-
-if ($action eq 'display') {
- push @{$template_data_ref->{errors}}, @errors;
-
- if ($type eq 'reset') {
- $template_data_ref->{token} = single_param('token');
- $template_data_ref->{resetid} = single_param('resetid');
- }
-}
-
-elsif ($action eq 'process') {
-
- if ($type eq 'send_email') {
- $template_data_ref->{status} = "error";
-
- if (defined $user_ref) {
-
- $user_ref->{token_t} = time();
- $user_ref->{token} = generate_token(64);
- $user_ref->{token_ip} = remote_addr();
-
- store_user_session($user_ref);
- my $userid = $user_ref->{userid};
-
- my $url
- = format_subdomain($subdomain)
- . "/cgi/reset_password.pl?type=reset&resetid=$userid&token="
- . $user_ref->{token};
-
- my $email = lang("reset_password_email_body");
- $email =~ s//$userid/g;
- $email =~ s//$url/g;
- send_email($user_ref, lang("reset_password_email_subject"), $email);
-
- $template_data_ref->{status} = "email_sent";
- }
- }
- elsif ($type eq 'reset') {
- my $userid = single_param('resetid');
- my $user_ref = retrieve_user($userid);
-
- $log->debug("resetting password", {userid => $userid}) if $log->is_debug();
-
- $template_data_ref->{status} = "error";
-
- if (defined $user_ref) {
-
- if ( (defined $user_ref->{token})
- and (defined single_param('token'))
- and (single_param('token') eq $user_ref->{token})
- and (time() < ($user_ref->{token_t} + 86400 * 3)))
- {
-
- $log->debug("token is valid, updating password", {userid => $userid}) if $log->is_debug();
-
- $template_data_ref->{status} = "password_reset";
-
- $user_ref->{encrypted_password}
- = create_password_hash(encode_utf8(decode utf8 => single_param('password')));
-
- delete $user_ref->{token};
-
- store_user($user_ref);
-
- }
- else {
- $log->debug("token is invalid", {userid => $userid}) if $log->is_debug();
- display_error_and_exit($request_ref, $Lang{error_reset_invalid_token}{$lc}, undef);
- }
- }
- }
-}
-
-process_template('web/pages/reset_password/reset_password.tt.html', $template_data_ref, \$html)
- or $html = "
" . $tt->error() . "
";
-
-$request_ref->{title} = $Lang{'reset_password'}{$lc};
-$request_ref->{content_ref} = \$html;
-display_page($request_ref);
-
diff --git a/cgi/session.pl b/cgi/session.pl
index 655159ab2189f..63686e5ec5ac0 100755
--- a/cgi/session.pl
+++ b/cgi/session.pl
@@ -3,7 +3,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -31,6 +31,7 @@
use ProductOpener::HTTP qw/write_cors_headers/;
use ProductOpener::Users qw/$User_id %User/;
use ProductOpener::Lang qw/lang/;
+use ProductOpener::Auth qw/write_auth_deprecated_headers/;
use CGI qw/:cgi :form escapeHTML/;
use URI::Escape::XS;
@@ -75,6 +76,7 @@
$log->info("redirecting after login", {url => $url}) if $log->is_info();
+ write_auth_deprecated_headers();
$r->err_headers_out->add('Set-Cookie' => $request_ref->{cookie});
$r->headers_out->set(Location => "$url");
$r->status(302);
@@ -95,6 +97,7 @@
my $data = encode_json(\%response);
write_cors_headers();
+ write_auth_deprecated_headers();
print header(-type => 'application/json', -charset => 'utf-8') . $data;
}
@@ -119,6 +122,8 @@
$request_ref->{title} = lang('session_title');
$request_ref->{content_ref} = \$html;
+
+ write_auth_deprecated_headers();
display_page($request_ref);
}
diff --git a/cgi/sso.pl b/cgi/sso.pl
index 576241dff8c0e..dacaed7e41caa 100755
--- a/cgi/sso.pl
+++ b/cgi/sso.pl
@@ -28,6 +28,7 @@
use ProductOpener::Store qw/:all/;
use ProductOpener::Users qw/check_session/;
use ProductOpener::Display qw/single_param/;
+use ProductOpener::Auth qw/write_auth_deprecated_headers/;
use CGI qw/:cgi :form escapeHTML/;
use URI::Escape::XS;
@@ -44,4 +45,5 @@
my $data = encode_json($response_ref);
+write_auth_deprecated_headers();
print header(-type => 'application/json', -charset => 'utf-8') . $data;
diff --git a/cgi/user.pl b/cgi/user.pl
index 098f3bf09394d..074a92798bb51 100644
--- a/cgi/user.pl
+++ b/cgi/user.pl
@@ -3,7 +3,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -33,6 +33,7 @@
use ProductOpener::Orgs qw/org_name retrieve_org/;
use ProductOpener::Text qw/remove_tags_and_quote/;
use ProductOpener::CRM qw/get_contact_url/;
+use ProductOpener::Keycloak;
use CGI qw/:cgi :form escapeHTML charset/;
use URI::Escape::XS;
@@ -71,9 +72,9 @@
# The userid looks like an e-mail
if ($request_ref->{admin} and ($userid =~ /\@/)) {
- my $user_by_email = retrieve_user_by_email($userid);
- if (defined $user_by_email) {
- $userid = $user_by_email->{userid};
+ my $mail_based_userid = is_email_has_off_account($userid);
+ if (defined $mail_based_userid) {
+ $userid = $mail_based_userid;
}
}
}
@@ -105,12 +106,6 @@
if ($action eq 'process') {
- if ($type eq 'edit') {
- if (single_param('delete') eq 'on') {
- $type = 'delete';
- }
- }
-
# change organization
if ($type eq 'edit_owner') {
# only admin and pro moderators can change organization freely
@@ -121,7 +116,7 @@
display_error_and_exit($request_ref, $Lang{error_no_permission}{$lc}, 403);
}
}
- elsif ($type ne 'delete') {
+ else {
ProductOpener::Users::check_user_form($request_ref, $type, $user_ref, \@errors);
}
@@ -154,12 +149,10 @@
$user_ref->{email} = $user_info;
$user_ref->{userid} = $1;
$user_ref->{name} = $1;
- $user_ref->{password} = $new_user_password;
}
else {
$user_ref->{userid} = $user_info;
$user_ref->{name} = $user_info;
- $user_ref->{password} = $new_user_password;
}
}
@@ -170,50 +163,13 @@
$template_data_ref->{sections} = [];
if ($user_ref) {
- my $selected_language = $user_ref->{preferred_language}
- // (remove_tags_and_quote(single_param('preferred_language')) || "$lc");
- my $selected_country = $user_ref->{country} // (remove_tags_and_quote(single_param('country')) || $country);
- if ($selected_country eq "en:world") {
- $selected_country = "";
- }
push @{$template_data_ref->{sections}}, {
id => "user",
fields => [
- {
- field => "name"
- },
- {
- field => "email",
- type => "email",
- },
{
field => "userid",
label => "username"
},
- {
- field => "password",
- type => "password",
- label => "password"
- },
- {
- field => "confirm_password",
- type => "password",
- label => "password_confirm"
- },
- {
- field => "preferred_language",
- type => "select",
- label => "preferred_language",
- selected => $selected_language,
- options => get_languages_options_list($lc),
- },
- {
- field => "country",
- type => "select",
- label => "select_country",
- selected => $selected_country,
- options => get_countries_options_list($lc),
- },
{
# this is a honeypot to detect scripts, that fills every fields
# this one is hidden in a div and user won't see it
@@ -403,9 +359,6 @@
if (($type eq 'add') or ($type =~ /^edit/)) {
ProductOpener::Users::process_user_form($type, $user_ref, $request_ref);
}
- elsif ($type eq 'delete') {
- ProductOpener::Users::delete_user($user_ref);
- }
if ($type eq 'add') {
diff --git a/conf/apache-2.4/modperl.conf b/conf/apache-2.4/modperl.conf
index fab32e5e2cd52..b3fdcaa877f14 100644
--- a/conf/apache-2.4/modperl.conf
+++ b/conf/apache-2.4/modperl.conf
@@ -30,4 +30,10 @@ PerlPassEnv RATE_LIMITER_BLOCKING_ENABLED
PerlPassEnv ODOO_CRM_URL
PerlPassEnv ODOO_CRM_DB
PerlPassEnv ODOO_CRM_USER
-PerlPassEnv ODOO_CRM_PASSWORD
\ No newline at end of file
+PerlPassEnv ODOO_CRM_PASSWORD
+PerlPassEnv KEYCLOAK_BASE_URL
+PerlPassEnv KEYCLOAK_BACKCHANNEL_BASE_URL
+PerlPassEnv KEYCLOAK_REALM_NAME
+PerlPassEnv PRODUCT_OPENER_OIDC_CLIENT_ID
+PerlPassEnv PRODUCT_OPENER_OIDC_CLIENT_SECRET
+PerlPassEnv PRODUCT_OPENER_OIDC_DISCOVERY_ENDPOINT
\ No newline at end of file
diff --git a/conf/keycloak/open-products-facts-realm.json b/conf/keycloak/open-products-facts-realm.json
new file mode 100644
index 0000000000000..26a63134ee18f
--- /dev/null
+++ b/conf/keycloak/open-products-facts-realm.json
@@ -0,0 +1,2498 @@
+{
+ "id": "793a2761-1af2-44e1-a0b8-cc37a030a2af",
+ "realm": "open-products-facts",
+ "displayName": "Open Products Facts",
+ "displayNameHtml": "",
+ "notBefore": 0,
+ "defaultSignatureAlgorithm": "RS256",
+ "revokeRefreshToken": false,
+ "refreshTokenMaxReuse": 0,
+ "accessTokenLifespan": 300,
+ "accessTokenLifespanForImplicitFlow": 900,
+ "ssoSessionIdleTimeout": 1800,
+ "ssoSessionMaxLifespan": 36000,
+ "ssoSessionIdleTimeoutRememberMe": 0,
+ "ssoSessionMaxLifespanRememberMe": 0,
+ "offlineSessionIdleTimeout": 2592000,
+ "offlineSessionMaxLifespanEnabled": false,
+ "offlineSessionMaxLifespan": 5184000,
+ "clientSessionIdleTimeout": 0,
+ "clientSessionMaxLifespan": 0,
+ "clientOfflineSessionIdleTimeout": 0,
+ "clientOfflineSessionMaxLifespan": 0,
+ "accessCodeLifespan": 60,
+ "accessCodeLifespanUserAction": 300,
+ "accessCodeLifespanLogin": 1800,
+ "actionTokenGeneratedByAdminLifespan": 43200,
+ "actionTokenGeneratedByUserLifespan": 300,
+ "oauth2DeviceCodeLifespan": 600,
+ "oauth2DevicePollingInterval": 5,
+ "enabled": true,
+ "sslRequired": "external",
+ "registrationAllowed": true,
+ "registrationEmailAsUsername": false,
+ "rememberMe": false,
+ "verifyEmail": false,
+ "loginWithEmailAllowed": true,
+ "duplicateEmailsAllowed": false,
+ "resetPasswordAllowed": true,
+ "editUsernameAllowed": false,
+ "bruteForceProtected": false,
+ "permanentLockout": false,
+ "maxFailureWaitSeconds": 900,
+ "minimumQuickLoginWaitSeconds": 60,
+ "waitIncrementSeconds": 60,
+ "quickLoginCheckMilliSeconds": 1000,
+ "maxDeltaTimeSeconds": 43200,
+ "failureFactor": 30,
+ "roles": {
+ "realm": [
+ {
+ "id": "ee81baa9-5677-45ff-9c1f-1b24ef49b30e",
+ "name": "uma_authorization",
+ "description": "${role_uma_authorization}",
+ "composite": false,
+ "clientRole": false,
+ "containerId": "793a2761-1af2-44e1-a0b8-cc37a030a2af",
+ "attributes": {}
+ },
+ {
+ "id": "6254728b-ae51-4873-bcb3-3d9854845b00",
+ "name": "default-roles-open-products-facts",
+ "description": "${role_default-roles}",
+ "composite": true,
+ "composites": {
+ "realm": [
+ "offline_access",
+ "uma_authorization"
+ ],
+ "client": {
+ "account": [
+ "view-profile",
+ "delete-account",
+ "manage-account"
+ ]
+ }
+ },
+ "clientRole": false,
+ "containerId": "793a2761-1af2-44e1-a0b8-cc37a030a2af",
+ "attributes": {}
+ },
+ {
+ "id": "fdff2d5b-16f5-4c12-8b4a-76d033b14d69",
+ "name": "offline_access",
+ "description": "${role_offline-access}",
+ "composite": false,
+ "clientRole": false,
+ "containerId": "793a2761-1af2-44e1-a0b8-cc37a030a2af",
+ "attributes": {}
+ }
+ ],
+ "client": {
+ "realm-management": [
+ {
+ "id": "66d72c18-2dfe-4ae1-a7b2-c1304c1160b2",
+ "name": "query-users",
+ "description": "${role_query-users}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "3e13d885-1b39-42ac-b9e4-e648ce07092a",
+ "name": "manage-authorization",
+ "description": "${role_manage-authorization}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "a1a2686c-4180-4225-a85e-9872c63a11b5",
+ "name": "view-events",
+ "description": "${role_view-events}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "88ca8408-13d1-4d92-aa99-bb792e125572",
+ "name": "view-identity-providers",
+ "description": "${role_view-identity-providers}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "3f0ab4f3-03ad-48db-ac07-6ed47b59955a",
+ "name": "view-clients",
+ "description": "${role_view-clients}",
+ "composite": true,
+ "composites": {
+ "client": {
+ "realm-management": [
+ "query-clients"
+ ]
+ }
+ },
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "b80ae327-2b86-452b-a3d6-deb6f811beb2",
+ "name": "manage-identity-providers",
+ "description": "${role_manage-identity-providers}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "efbb7afe-2c7c-454f-bc1e-9caca15222ce",
+ "name": "create-client",
+ "description": "${role_create-client}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "b4339138-dbc8-4fc2-8692-fa757a1a5cd4",
+ "name": "view-users",
+ "description": "${role_view-users}",
+ "composite": true,
+ "composites": {
+ "client": {
+ "realm-management": [
+ "query-groups",
+ "query-users"
+ ]
+ }
+ },
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "23cfdbd1-32c7-4616-ab20-3eeea3ac462e",
+ "name": "view-authorization",
+ "description": "${role_view-authorization}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "17c042a0-cd6b-4f64-a6a0-b579828bce32",
+ "name": "manage-clients",
+ "description": "${role_manage-clients}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "4e719a81-d8e3-419d-aa58-52506b8d78ee",
+ "name": "query-groups",
+ "description": "${role_query-groups}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "6da1af85-2a85-427d-983c-4faead48871a",
+ "name": "manage-realm",
+ "description": "${role_manage-realm}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "37970203-2be4-478c-a00b-7b35142f7915",
+ "name": "view-realm",
+ "description": "${role_view-realm}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "f2d9eff6-c191-43fd-8e12-54b996a972a0",
+ "name": "manage-users",
+ "description": "${role_manage-users}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "6c39ce91-d0a1-4196-9726-d86bb7c3fc76",
+ "name": "query-clients",
+ "description": "${role_query-clients}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "2e0a9cf2-3a31-42b4-9bee-ad0b9b0421c3",
+ "name": "query-realms",
+ "description": "${role_query-realms}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "299a944e-1152-42be-b812-aa6768345ca5",
+ "name": "manage-events",
+ "description": "${role_manage-events}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "bbbb1ac2-0141-4ae2-9651-e6beeaac082d",
+ "name": "realm-admin",
+ "description": "${role_realm-admin}",
+ "composite": true,
+ "composites": {
+ "client": {
+ "realm-management": [
+ "query-users",
+ "manage-authorization",
+ "view-identity-providers",
+ "view-events",
+ "view-clients",
+ "manage-identity-providers",
+ "create-client",
+ "view-users",
+ "view-authorization",
+ "manage-clients",
+ "query-groups",
+ "view-realm",
+ "manage-realm",
+ "manage-users",
+ "query-clients",
+ "query-realms",
+ "manage-events",
+ "impersonation"
+ ]
+ }
+ },
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ },
+ {
+ "id": "fb664e6a-778e-4a85-abb5-9af2eec98ac4",
+ "name": "impersonation",
+ "description": "${role_impersonation}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "attributes": {}
+ }
+ ],
+ "hoppscotch": [
+ {
+ "id": "512f0e38-1503-4090-baf2-bf363ab0e830",
+ "name": "uma_protection",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "4aa0f015-3b71-4aef-8cc9-6e9ddd814217",
+ "attributes": {}
+ }
+ ],
+ "security-admin-console": [],
+ "${PRODUCT_OPENER_OIDC_CLIENT_ID}": [
+ {
+ "id": "beda95d0-edcf-4387-a431-a642df8b62b8",
+ "name": "uma_protection",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "c865387e-1275-47f7-948a-fd1b4b166385",
+ "attributes": {}
+ }
+ ],
+ "admin-cli": [],
+ "account-console": [],
+ "broker": [
+ {
+ "id": "5f7bf7a7-32ee-4b9f-aa6f-98ec731e2f36",
+ "name": "read-token",
+ "description": "${role_read-token}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "0fb7edcc-ba9a-44ab-bc39-55a1343f342a",
+ "attributes": {}
+ }
+ ],
+ "account": [
+ {
+ "id": "46ee03c6-0a79-4dfc-8502-638dfc9f2d61",
+ "name": "view-profile",
+ "description": "${role_view-profile}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652",
+ "attributes": {}
+ },
+ {
+ "id": "cf7ce816-056f-4c5c-bcb1-a066a7a6cd36",
+ "name": "delete-account",
+ "description": "${role_delete-account}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652",
+ "attributes": {}
+ },
+ {
+ "id": "1dd5dd45-59a6-439a-b971-9b1e70d1b75f",
+ "name": "manage-consent",
+ "description": "${role_manage-consent}",
+ "composite": true,
+ "composites": {
+ "client": {
+ "account": [
+ "view-consent"
+ ]
+ }
+ },
+ "clientRole": true,
+ "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652",
+ "attributes": {}
+ },
+ {
+ "id": "71219f0a-0ba0-4521-a14e-a7d44a0b18ee",
+ "name": "manage-account",
+ "description": "${role_manage-account}",
+ "composite": true,
+ "composites": {
+ "client": {
+ "account": [
+ "manage-account-links"
+ ]
+ }
+ },
+ "clientRole": true,
+ "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652",
+ "attributes": {}
+ },
+ {
+ "id": "af2495f7-4698-4e29-a3c8-d4a5b8ef0a99",
+ "name": "manage-account-links",
+ "description": "${role_manage-account-links}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652",
+ "attributes": {}
+ },
+ {
+ "id": "c441a16c-59ef-43bc-9526-4e81f09af158",
+ "name": "view-consent",
+ "description": "${role_view-consent}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652",
+ "attributes": {}
+ },
+ {
+ "id": "5a4b1f2e-3771-4ad5-8d5f-e79e19e7f2e6",
+ "name": "view-groups",
+ "description": "${role_view-groups}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652",
+ "attributes": {}
+ },
+ {
+ "id": "9271fdb4-6c46-43dd-b1bf-288211bf786f",
+ "name": "view-applications",
+ "description": "${role_view-applications}",
+ "composite": false,
+ "clientRole": true,
+ "containerId": "5ca80ba2-8030-4f5f-909c-dab3221dc652",
+ "attributes": {}
+ }
+ ]
+ }
+ },
+ "groups": [],
+ "defaultRole": {
+ "id": "6254728b-ae51-4873-bcb3-3d9854845b00",
+ "name": "default-roles-open-products-facts",
+ "description": "${role_default-roles}",
+ "composite": true,
+ "clientRole": false,
+ "containerId": "793a2761-1af2-44e1-a0b8-cc37a030a2af"
+ },
+ "requiredCredentials": [
+ "password"
+ ],
+ "passwordPolicy": "passwordHistory(2) and notUsername(undefined) and notEmail(undefined) and length(12)",
+ "otpPolicyType": "totp",
+ "otpPolicyAlgorithm": "HmacSHA512",
+ "otpPolicyInitialCounter": 0,
+ "otpPolicyDigits": 8,
+ "otpPolicyLookAheadWindow": 1,
+ "otpPolicyPeriod": 30,
+ "otpPolicyCodeReusable": false,
+ "otpSupportedApplications": [
+ "totpAppFreeOTPName"
+ ],
+ "localizationTexts": {},
+ "webAuthnPolicyRpEntityName": "Open Products Facts",
+ "webAuthnPolicySignatureAlgorithms": [
+ "ES256"
+ ],
+ "webAuthnPolicyRpId": "",
+ "webAuthnPolicyAttestationConveyancePreference": "not specified",
+ "webAuthnPolicyAuthenticatorAttachment": "not specified",
+ "webAuthnPolicyRequireResidentKey": "not specified",
+ "webAuthnPolicyUserVerificationRequirement": "not specified",
+ "webAuthnPolicyCreateTimeout": 0,
+ "webAuthnPolicyAvoidSameAuthenticatorRegister": false,
+ "webAuthnPolicyAcceptableAaguids": [],
+ "webAuthnPolicyExtraOrigins": [],
+ "webAuthnPolicyPasswordlessRpEntityName": "Open Products Facts",
+ "webAuthnPolicyPasswordlessSignatureAlgorithms": [
+ "ES256"
+ ],
+ "webAuthnPolicyPasswordlessRpId": "",
+ "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified",
+ "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified",
+ "webAuthnPolicyPasswordlessRequireResidentKey": "not specified",
+ "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified",
+ "webAuthnPolicyPasswordlessCreateTimeout": 0,
+ "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false,
+ "webAuthnPolicyPasswordlessAcceptableAaguids": [],
+ "webAuthnPolicyPasswordlessExtraOrigins": [],
+ "users": [
+ {
+ "id": "c3940479-7649-4562-9890-860d04d1d06e",
+ "createdTimestamp": 1700482310356,
+ "username": "service-account-productopener",
+ "enabled": true,
+ "totp": false,
+ "emailVerified": false,
+ "serviceAccountClientId": "${PRODUCT_OPENER_OIDC_CLIENT_ID}",
+ "disableableCredentialTypes": [],
+ "requiredActions": [],
+ "realmRoles": [
+ "default-roles-open-products-facts"
+ ],
+ "clientRoles": {
+ "realm-management": [
+ "manage-users",
+ "query-users"
+ ]
+ },
+ "notBefore": 0,
+ "groups": []
+ }
+ ],
+ "scopeMappings": [
+ {
+ "clientScope": "offline_access",
+ "roles": [
+ "offline_access"
+ ]
+ }
+ ],
+ "clientScopeMappings": {
+ "account": [
+ {
+ "client": "account-console",
+ "roles": [
+ "manage-account",
+ "view-groups"
+ ]
+ }
+ ]
+ },
+ "clients": [
+ {
+ "id": "c865387e-1275-47f7-948a-fd1b4b166385",
+ "clientId": "${PRODUCT_OPENER_OIDC_CLIENT_ID}",
+ "name": "${PRODUCT_OPENER_OIDC_CLIENT_ID}",
+ "description": "Dummy client for local development",
+ "rootUrl": "http://world.${PRODUCT_OPENER_DOMAIN}/",
+ "adminUrl": "http://world.${PRODUCT_OPENER_DOMAIN}/",
+ "baseUrl": "http://world.${PRODUCT_OPENER_DOMAIN}/",
+ "surrogateAuthRequired": false,
+ "enabled": true,
+ "alwaysDisplayInConsole": true,
+ "clientAuthenticatorType": "client-secret",
+ "secret": "${PRODUCT_OPENER_OIDC_CLIENT_SECRET}",
+ "redirectUris": [
+ "http://world.${PRODUCT_OPENER_DOMAIN}/cgi/oidc_signin_callback.pl"
+ ],
+ "webOrigins": [
+ "*"
+ ],
+ "notBefore": 0,
+ "bearerOnly": false,
+ "consentRequired": false,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": true,
+ "serviceAccountsEnabled": true,
+ "publicClient": false,
+ "frontchannelLogout": true,
+ "protocol": "openid-connect",
+ "attributes": {
+ "oidc.ciba.grant.enabled": "false",
+ "client.secret.creation.time": "1698609487",
+ "backchannel.logout.session.required": "true",
+ "post.logout.redirect.uris": "http://world.${PRODUCT_OPENER_DOMAIN}/cgi/oidc_signout_callback.pl",
+ "oauth2.device.authorization.grant.enabled": "false",
+ "display.on.consent.screen": "false",
+ "backchannel.logout.revoke.offline.tokens": "false"
+ },
+ "authenticationFlowBindingOverrides": {},
+ "fullScopeAllowed": true,
+ "nodeReRegistrationTimeout": -1,
+ "protocolMappers": [
+ {
+ "id": "706932c1-de97-4528-abb0-386632953e06",
+ "name": "Client ID",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usersessionmodel-note-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.session.note": "client_id",
+ "userinfo.token.claim": "true",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "client_id",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "a762ff13-6c41-4bb9-9314-a918a815815d",
+ "name": "Client IP Address",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usersessionmodel-note-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.session.note": "clientAddress",
+ "userinfo.token.claim": "true",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "clientAddress",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "658cf99e-9f09-4294-80f2-6fc2656c2674",
+ "name": "Client Host",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usersessionmodel-note-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.session.note": "clientHost",
+ "userinfo.token.claim": "true",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "clientHost",
+ "jsonType.label": "String"
+ }
+ }
+ ],
+ "defaultClientScopes": [
+ "web-origins",
+ "acr",
+ "openid",
+ "profile",
+ "roles",
+ "email"
+ ],
+ "optionalClientScopes": [
+ "address",
+ "phone",
+ "offline_access",
+ "microprofile-jwt"
+ ]
+ },
+ {
+ "id": "5ca80ba2-8030-4f5f-909c-dab3221dc652",
+ "clientId": "account",
+ "name": "${client_account}",
+ "rootUrl": "${authBaseUrl}",
+ "baseUrl": "/realms/open-products-facts/account/",
+ "surrogateAuthRequired": false,
+ "enabled": true,
+ "alwaysDisplayInConsole": false,
+ "clientAuthenticatorType": "client-secret",
+ "redirectUris": [
+ "/realms/open-products-facts/account/*"
+ ],
+ "webOrigins": [],
+ "notBefore": 0,
+ "bearerOnly": false,
+ "consentRequired": false,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": false,
+ "publicClient": true,
+ "frontchannelLogout": false,
+ "protocol": "openid-connect",
+ "attributes": {
+ "post.logout.redirect.uris": "+"
+ },
+ "authenticationFlowBindingOverrides": {},
+ "fullScopeAllowed": false,
+ "nodeReRegistrationTimeout": 0,
+ "defaultClientScopes": [
+ "web-origins",
+ "acr",
+ "profile",
+ "roles",
+ "email"
+ ],
+ "optionalClientScopes": [
+ "address",
+ "phone",
+ "offline_access",
+ "microprofile-jwt"
+ ]
+ },
+ {
+ "id": "9824b957-e826-4d4f-ae3b-da43c77ac7fe",
+ "clientId": "account-console",
+ "name": "${client_account-console}",
+ "rootUrl": "${authBaseUrl}",
+ "baseUrl": "/realms/open-products-facts/account/",
+ "surrogateAuthRequired": false,
+ "enabled": true,
+ "alwaysDisplayInConsole": false,
+ "clientAuthenticatorType": "client-secret",
+ "redirectUris": [
+ "/realms/open-products-facts/account/*"
+ ],
+ "webOrigins": [],
+ "notBefore": 0,
+ "bearerOnly": false,
+ "consentRequired": false,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": false,
+ "publicClient": true,
+ "frontchannelLogout": false,
+ "protocol": "openid-connect",
+ "attributes": {
+ "post.logout.redirect.uris": "+",
+ "pkce.code.challenge.method": "S256"
+ },
+ "authenticationFlowBindingOverrides": {},
+ "fullScopeAllowed": false,
+ "nodeReRegistrationTimeout": 0,
+ "protocolMappers": [
+ {
+ "id": "5350bfb5-5e53-41e1-81f5-218aa371cc32",
+ "name": "audience resolve",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-resolve-mapper",
+ "consentRequired": false,
+ "config": {}
+ }
+ ],
+ "defaultClientScopes": [
+ "web-origins",
+ "acr",
+ "profile",
+ "roles",
+ "email"
+ ],
+ "optionalClientScopes": [
+ "address",
+ "phone",
+ "offline_access",
+ "microprofile-jwt"
+ ]
+ },
+ {
+ "id": "e1a63130-d2cb-4618-a11c-43616af049ae",
+ "clientId": "admin-cli",
+ "name": "${client_admin-cli}",
+ "surrogateAuthRequired": false,
+ "enabled": true,
+ "alwaysDisplayInConsole": false,
+ "clientAuthenticatorType": "client-secret",
+ "redirectUris": [],
+ "webOrigins": [],
+ "notBefore": 0,
+ "bearerOnly": false,
+ "consentRequired": false,
+ "standardFlowEnabled": false,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": true,
+ "serviceAccountsEnabled": false,
+ "publicClient": true,
+ "frontchannelLogout": false,
+ "protocol": "openid-connect",
+ "attributes": {
+ "post.logout.redirect.uris": "+"
+ },
+ "authenticationFlowBindingOverrides": {},
+ "fullScopeAllowed": false,
+ "nodeReRegistrationTimeout": 0,
+ "defaultClientScopes": [
+ "web-origins",
+ "acr",
+ "profile",
+ "roles",
+ "email"
+ ],
+ "optionalClientScopes": [
+ "address",
+ "phone",
+ "offline_access",
+ "microprofile-jwt"
+ ]
+ },
+ {
+ "id": "0fb7edcc-ba9a-44ab-bc39-55a1343f342a",
+ "clientId": "broker",
+ "name": "${client_broker}",
+ "surrogateAuthRequired": false,
+ "enabled": true,
+ "alwaysDisplayInConsole": false,
+ "clientAuthenticatorType": "client-secret",
+ "redirectUris": [],
+ "webOrigins": [],
+ "notBefore": 0,
+ "bearerOnly": true,
+ "consentRequired": false,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": false,
+ "publicClient": false,
+ "frontchannelLogout": false,
+ "protocol": "openid-connect",
+ "attributes": {
+ "post.logout.redirect.uris": "+"
+ },
+ "authenticationFlowBindingOverrides": {},
+ "fullScopeAllowed": false,
+ "nodeReRegistrationTimeout": 0,
+ "defaultClientScopes": [
+ "web-origins",
+ "acr",
+ "profile",
+ "roles",
+ "email"
+ ],
+ "optionalClientScopes": [
+ "address",
+ "phone",
+ "offline_access",
+ "microprofile-jwt"
+ ]
+ },
+ {
+ "id": "4aa0f015-3b71-4aef-8cc9-6e9ddd814217",
+ "clientId": "hoppscotch",
+ "name": "hoppscotch",
+ "description": "",
+ "rootUrl": "https://hoppscotch.io/",
+ "adminUrl": "https://hoppscotch.io/",
+ "baseUrl": "https://hoppscotch.io/",
+ "surrogateAuthRequired": false,
+ "enabled": true,
+ "alwaysDisplayInConsole": false,
+ "clientAuthenticatorType": "client-secret",
+ "redirectUris": [
+ "https://hoppscotch.io/oauth"
+ ],
+ "webOrigins": [
+ "https://hoppscotch.io"
+ ],
+ "notBefore": 0,
+ "bearerOnly": false,
+ "consentRequired": false,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": true,
+ "directAccessGrantsEnabled": true,
+ "serviceAccountsEnabled": false,
+ "publicClient": true,
+ "frontchannelLogout": true,
+ "protocol": "openid-connect",
+ "attributes": {
+ "oidc.ciba.grant.enabled": "false",
+ "client.secret.creation.time": "1701004114",
+ "backchannel.logout.session.required": "true",
+ "post.logout.redirect.uris": "https://hoppscotch.io/",
+ "oauth2.device.authorization.grant.enabled": "true",
+ "display.on.consent.screen": "false",
+ "backchannel.logout.revoke.offline.tokens": "false"
+ },
+ "authenticationFlowBindingOverrides": {},
+ "fullScopeAllowed": true,
+ "nodeReRegistrationTimeout": -1,
+ "protocolMappers": [
+ {
+ "id": "6196a19f-9fee-41ce-bc2e-3859ddf460ac",
+ "name": "Client Host",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usersessionmodel-note-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.session.note": "clientHost",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "clientHost",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "406f0512-2b23-44f0-9678-12122bd33740",
+ "name": "Client ID",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usersessionmodel-note-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.session.note": "client_id",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "client_id",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "cc918f4f-54d4-4967-a08a-667e92a1658b",
+ "name": "Client IP Address",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usersessionmodel-note-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.session.note": "clientAddress",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "clientAddress",
+ "jsonType.label": "String"
+ }
+ }
+ ],
+ "defaultClientScopes": [
+ "web-origins",
+ "acr",
+ "profile",
+ "roles",
+ "email"
+ ],
+ "optionalClientScopes": [
+ "address",
+ "phone",
+ "offline_access",
+ "microprofile-jwt"
+ ]
+ },
+ {
+ "id": "4753f77d-ecb7-4944-9959-1321e7ef7b9a",
+ "clientId": "realm-management",
+ "name": "${client_realm-management}",
+ "surrogateAuthRequired": false,
+ "enabled": true,
+ "alwaysDisplayInConsole": false,
+ "clientAuthenticatorType": "client-secret",
+ "redirectUris": [],
+ "webOrigins": [],
+ "notBefore": 0,
+ "bearerOnly": true,
+ "consentRequired": false,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": false,
+ "publicClient": false,
+ "frontchannelLogout": false,
+ "protocol": "openid-connect",
+ "attributes": {
+ "post.logout.redirect.uris": "+"
+ },
+ "authenticationFlowBindingOverrides": {},
+ "fullScopeAllowed": false,
+ "nodeReRegistrationTimeout": 0,
+ "defaultClientScopes": [
+ "web-origins",
+ "acr",
+ "profile",
+ "roles",
+ "email"
+ ],
+ "optionalClientScopes": [
+ "address",
+ "phone",
+ "offline_access",
+ "microprofile-jwt"
+ ]
+ },
+ {
+ "id": "0d555a34-b029-415f-943c-ac012ffcb130",
+ "clientId": "security-admin-console",
+ "name": "${client_security-admin-console}",
+ "rootUrl": "${authAdminUrl}",
+ "baseUrl": "/admin/open-products-facts/console/",
+ "surrogateAuthRequired": false,
+ "enabled": true,
+ "alwaysDisplayInConsole": false,
+ "clientAuthenticatorType": "client-secret",
+ "redirectUris": [
+ "/admin/open-products-facts/console/*"
+ ],
+ "webOrigins": [
+ "+"
+ ],
+ "notBefore": 0,
+ "bearerOnly": false,
+ "consentRequired": false,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": false,
+ "publicClient": true,
+ "frontchannelLogout": false,
+ "protocol": "openid-connect",
+ "attributes": {
+ "post.logout.redirect.uris": "+",
+ "pkce.code.challenge.method": "S256"
+ },
+ "authenticationFlowBindingOverrides": {},
+ "fullScopeAllowed": false,
+ "nodeReRegistrationTimeout": 0,
+ "protocolMappers": [
+ {
+ "id": "f9eac11f-03bf-4cae-96e9-4a7398018a3f",
+ "name": "locale",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "locale",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "locale",
+ "jsonType.label": "String"
+ }
+ }
+ ],
+ "defaultClientScopes": [
+ "web-origins",
+ "acr",
+ "profile",
+ "roles",
+ "email"
+ ],
+ "optionalClientScopes": [
+ "address",
+ "phone",
+ "offline_access",
+ "microprofile-jwt"
+ ]
+ }
+ ],
+ "clientScopes": [
+ {
+ "id": "c7499cba-fd75-463a-91bf-2c2c97fc53c4",
+ "name": "acr",
+ "description": "OpenID Connect scope for add acr (authentication context class reference) to the token",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "display.on.consent.screen": "false"
+ },
+ "protocolMappers": [
+ {
+ "id": "c0ca0cf9-2d7c-4ae1-8106-2027fed1c526",
+ "name": "acr loa level",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-acr-mapper",
+ "consentRequired": false,
+ "config": {
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ }
+ ]
+ },
+ {
+ "id": "2a574392-a877-45ec-9e3d-cb2232e9588a",
+ "name": "microprofile-jwt",
+ "description": "Microprofile - JWT built-in scope",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "false"
+ },
+ "protocolMappers": [
+ {
+ "id": "d2959159-b03a-4a92-9c5b-2ff5549a6206",
+ "name": "upn",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "username",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "upn",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "767c475c-f091-444a-84f8-c7a5b7958bca",
+ "name": "groups",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-realm-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "multivalued": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "foo",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "groups",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "id": "a0ff4433-96a0-4ddc-98b4-e08a51d049f0",
+ "name": "web-origins",
+ "description": "OpenID Connect scope for add allowed web origins to the access token",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "display.on.consent.screen": "false",
+ "consent.screen.text": ""
+ },
+ "protocolMappers": [
+ {
+ "id": "ed584f54-23c5-4337-acd2-78e688bbbfd7",
+ "name": "allowed web origins",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-allowed-origins-mapper",
+ "consentRequired": false,
+ "config": {}
+ }
+ ]
+ },
+ {
+ "id": "a2e9bd68-a189-4cba-a3a5-08e6a989410b",
+ "name": "role_list",
+ "description": "SAML role list",
+ "protocol": "saml",
+ "attributes": {
+ "consent.screen.text": "${samlRoleListScopeConsentText}",
+ "display.on.consent.screen": "true"
+ },
+ "protocolMappers": [
+ {
+ "id": "d3b567b8-3eef-4bbd-b286-a14b70aa380a",
+ "name": "role list",
+ "protocol": "saml",
+ "protocolMapper": "saml-role-list-mapper",
+ "consentRequired": false,
+ "config": {
+ "single": "false",
+ "attribute.nameformat": "Basic",
+ "attribute.name": "Role"
+ }
+ }
+ ]
+ },
+ {
+ "id": "09d1bc31-22c4-43f2-82e3-ac122bfb1cb3",
+ "name": "phone",
+ "description": "OpenID Connect built-in scope: phone",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "${phoneScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "id": "5081ef31-f3bb-45ff-aa8e-2e828540097e",
+ "name": "phone number verified",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "phoneNumberVerified",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "phone_number_verified",
+ "jsonType.label": "boolean"
+ }
+ },
+ {
+ "id": "b0dbf5fb-e59b-4872-816e-d175506b0c89",
+ "name": "phone number",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "phoneNumber",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "phone_number",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "id": "e100d014-3f1d-4075-9777-643deeda8811",
+ "name": "profile",
+ "description": "OpenID Connect built-in scope: profile",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "${profileScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "id": "902e4bf6-5162-42a1-b4af-6e37bed27d3b",
+ "name": "given name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "firstName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "given_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "5f94628e-6b46-462c-b730-1fbf315f5135",
+ "name": "website",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "website",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "website",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "d17ab27c-4402-4073-876e-e7acbea84a2f",
+ "name": "username",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "username",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "preferred_username",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "cc8c509e-4cd5-4eef-ad30-6765b7e7a493",
+ "name": "birthdate",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "birthdate",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "birthdate",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "d70f5b9c-ceaa-4d9a-bec8-31ddec27280e",
+ "name": "zoneinfo",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "zoneinfo",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "zoneinfo",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "57df491b-fe9a-41dc-945a-347da30f3599",
+ "name": "locale",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "locale",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "locale",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "bf0a66f9-33d6-4b77-afd9-1b2a00aeefc1",
+ "name": "updated at",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "updatedAt",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "updated_at",
+ "jsonType.label": "long"
+ }
+ },
+ {
+ "id": "e1dd1206-5607-4e29-92af-bf321eadb31b",
+ "name": "full name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-full-name-mapper",
+ "consentRequired": false,
+ "config": {
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ },
+ {
+ "id": "975562f7-f636-4ee9-92b9-adb62fa89b7e",
+ "name": "picture",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "picture",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "picture",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "f5bdaafa-8a95-4832-a66f-e86b0b536d6e",
+ "name": "family name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "lastName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "family_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "1c3e5f92-db44-4326-8c4e-1c905efe0fd3",
+ "name": "profile",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "profile",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "profile",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "fdf2590d-db0f-462f-91bf-97f21e34ab35",
+ "name": "middle name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "middleName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "middle_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "a5b2c3dc-1cfb-4ba3-ac9b-83d03f0bbdbc",
+ "name": "nickname",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "nickname",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "nickname",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "bcdd0ae3-7a76-455a-bd75-0b504f09f872",
+ "name": "gender",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "gender",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "gender",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "id": "79dc30bd-d1e1-4204-9311-1373e521c182",
+ "name": "address",
+ "description": "OpenID Connect built-in scope: address",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "${addressScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "id": "b31e7709-4f24-4738-96b6-a5bf2acb89e3",
+ "name": "address",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-address-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute.formatted": "formatted",
+ "user.attribute.country": "country",
+ "user.attribute.postal_code": "postal_code",
+ "userinfo.token.claim": "true",
+ "user.attribute.street": "street",
+ "id.token.claim": "true",
+ "user.attribute.region": "region",
+ "access.token.claim": "true",
+ "user.attribute.locality": "locality"
+ }
+ }
+ ]
+ },
+ {
+ "id": "1c85b4a1-30c2-4cf3-aed9-1009b170d8cd",
+ "name": "email",
+ "description": "OpenID Connect built-in scope: email",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "${emailScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "id": "ba8c1b78-e58f-4fdd-bcbf-45a14fe2ee5d",
+ "name": "email verified",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "emailVerified",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "email_verified",
+ "jsonType.label": "boolean"
+ }
+ },
+ {
+ "id": "e316fc71-abdb-4cda-b0a6-a6716949a66f",
+ "name": "email",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "email",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "email",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "id": "e6e22dd5-5636-4682-a865-cf2224ed3d53",
+ "name": "openid",
+ "description": "OpenID Connect built-in scope: openid",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "gui.order": "",
+ "consent.screen.text": ""
+ }
+ },
+ {
+ "id": "4b53d281-e9f0-4eb4-8045-8c6069c90347",
+ "name": "roles",
+ "description": "OpenID Connect scope for add user roles to the access token",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "${rolesScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "id": "d1dec85d-aa84-4cbd-9091-afddd4ab9060",
+ "name": "realm roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-realm-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute": "foo",
+ "access.token.claim": "true",
+ "claim.name": "realm_access.roles",
+ "jsonType.label": "String",
+ "multivalued": "true"
+ }
+ },
+ {
+ "id": "c03d266a-0c5b-4fab-b1f2-a8b6c1913ee2",
+ "name": "client roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-client-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute": "foo",
+ "access.token.claim": "true",
+ "claim.name": "resource_access.${client_id}.roles",
+ "jsonType.label": "String",
+ "multivalued": "true"
+ }
+ },
+ {
+ "id": "462cabcf-b519-4c06-b125-a3ff8ada34d2",
+ "name": "audience resolve",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-resolve-mapper",
+ "consentRequired": false,
+ "config": {}
+ }
+ ]
+ },
+ {
+ "id": "1304cdff-edd6-424a-ba73-a74d37de6c68",
+ "name": "offline_access",
+ "description": "OpenID Connect built-in scope: offline_access",
+ "protocol": "openid-connect",
+ "attributes": {
+ "consent.screen.text": "${offlineAccessScopeConsentText}",
+ "display.on.consent.screen": "true"
+ }
+ }
+ ],
+ "defaultDefaultClientScopes": [
+ "role_list",
+ "profile",
+ "email",
+ "roles",
+ "web-origins",
+ "acr"
+ ],
+ "defaultOptionalClientScopes": [
+ "offline_access",
+ "address",
+ "phone",
+ "microprofile-jwt"
+ ],
+ "browserSecurityHeaders": {
+ "contentSecurityPolicyReportOnly": "",
+ "xContentTypeOptions": "nosniff",
+ "referrerPolicy": "no-referrer",
+ "xRobotsTag": "none",
+ "xFrameOptions": "SAMEORIGIN",
+ "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';",
+ "xXSSProtection": "1; mode=block",
+ "strictTransportSecurity": "max-age=31536000; includeSubDomains"
+ },
+ "smtpServer": {},
+ "loginTheme": "off",
+ "accountTheme": "off",
+ "eventsEnabled": true,
+ "eventsExpiration": 7776000,
+ "eventsListeners": [
+ "jboss-logging",
+ "persist-deleted-user-event-listener",
+ "redis-event-listener"
+ ],
+ "enabledEventTypes": [
+ "SEND_RESET_PASSWORD",
+ "UPDATE_CONSENT_ERROR",
+ "GRANT_CONSENT",
+ "VERIFY_PROFILE_ERROR",
+ "REMOVE_TOTP",
+ "REVOKE_GRANT",
+ "UPDATE_TOTP",
+ "LOGIN_ERROR",
+ "CLIENT_LOGIN",
+ "RESET_PASSWORD_ERROR",
+ "IMPERSONATE_ERROR",
+ "CODE_TO_TOKEN_ERROR",
+ "CUSTOM_REQUIRED_ACTION",
+ "OAUTH2_DEVICE_CODE_TO_TOKEN_ERROR",
+ "RESTART_AUTHENTICATION",
+ "IMPERSONATE",
+ "UPDATE_PROFILE_ERROR",
+ "LOGIN",
+ "OAUTH2_DEVICE_VERIFY_USER_CODE",
+ "UPDATE_PASSWORD_ERROR",
+ "CLIENT_INITIATED_ACCOUNT_LINKING",
+ "OAUTH2_EXTENSION_GRANT",
+ "USER_DISABLED_BY_PERMANENT_LOCKOUT",
+ "TOKEN_EXCHANGE",
+ "AUTHREQID_TO_TOKEN",
+ "LOGOUT",
+ "REGISTER",
+ "DELETE_ACCOUNT_ERROR",
+ "CLIENT_REGISTER",
+ "IDENTITY_PROVIDER_LINK_ACCOUNT",
+ "USER_DISABLED_BY_TEMPORARY_LOCKOUT",
+ "DELETE_ACCOUNT",
+ "UPDATE_PASSWORD",
+ "CLIENT_DELETE",
+ "FEDERATED_IDENTITY_LINK_ERROR",
+ "IDENTITY_PROVIDER_FIRST_LOGIN",
+ "CLIENT_DELETE_ERROR",
+ "VERIFY_EMAIL",
+ "CLIENT_LOGIN_ERROR",
+ "RESTART_AUTHENTICATION_ERROR",
+ "EXECUTE_ACTIONS",
+ "REMOVE_FEDERATED_IDENTITY_ERROR",
+ "TOKEN_EXCHANGE_ERROR",
+ "PERMISSION_TOKEN",
+ "SEND_IDENTITY_PROVIDER_LINK_ERROR",
+ "EXECUTE_ACTION_TOKEN_ERROR",
+ "OAUTH2_EXTENSION_GRANT_ERROR",
+ "SEND_VERIFY_EMAIL",
+ "OAUTH2_DEVICE_AUTH",
+ "EXECUTE_ACTIONS_ERROR",
+ "REMOVE_FEDERATED_IDENTITY",
+ "OAUTH2_DEVICE_CODE_TO_TOKEN",
+ "IDENTITY_PROVIDER_POST_LOGIN",
+ "IDENTITY_PROVIDER_LINK_ACCOUNT_ERROR",
+ "OAUTH2_DEVICE_VERIFY_USER_CODE_ERROR",
+ "UPDATE_EMAIL",
+ "REGISTER_ERROR",
+ "REVOKE_GRANT_ERROR",
+ "EXECUTE_ACTION_TOKEN",
+ "LOGOUT_ERROR",
+ "UPDATE_EMAIL_ERROR",
+ "CLIENT_UPDATE_ERROR",
+ "AUTHREQID_TO_TOKEN_ERROR",
+ "UPDATE_PROFILE",
+ "CLIENT_REGISTER_ERROR",
+ "FEDERATED_IDENTITY_LINK",
+ "SEND_IDENTITY_PROVIDER_LINK",
+ "SEND_VERIFY_EMAIL_ERROR",
+ "RESET_PASSWORD",
+ "CLIENT_INITIATED_ACCOUNT_LINKING_ERROR",
+ "OAUTH2_DEVICE_AUTH_ERROR",
+ "UPDATE_CONSENT",
+ "REMOVE_TOTP_ERROR",
+ "VERIFY_EMAIL_ERROR",
+ "SEND_RESET_PASSWORD_ERROR",
+ "CLIENT_UPDATE",
+ "CUSTOM_REQUIRED_ACTION_ERROR",
+ "IDENTITY_PROVIDER_POST_LOGIN_ERROR",
+ "UPDATE_TOTP_ERROR",
+ "CODE_TO_TOKEN",
+ "VERIFY_PROFILE",
+ "GRANT_CONSENT_ERROR",
+ "IDENTITY_PROVIDER_FIRST_LOGIN_ERROR"
+ ],
+ "adminEventsEnabled": true,
+ "adminEventsDetailsEnabled": true,
+ "identityProviders": [],
+ "identityProviderMappers": [],
+ "components": {
+ "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
+ {
+ "id": "b183dcd5-f711-4c95-9307-c3ac90ede96c",
+ "name": "Allowed Protocol Mapper Types",
+ "providerId": "allowed-protocol-mappers",
+ "subType": "authenticated",
+ "subComponents": {},
+ "config": {
+ "allowed-protocol-mapper-types": [
+ "oidc-full-name-mapper",
+ "oidc-usermodel-property-mapper",
+ "saml-user-property-mapper",
+ "saml-role-list-mapper",
+ "oidc-address-mapper",
+ "oidc-usermodel-attribute-mapper",
+ "oidc-sha256-pairwise-sub-mapper",
+ "saml-user-attribute-mapper"
+ ]
+ }
+ },
+ {
+ "id": "539c0fae-f869-470d-b219-058562d2d218",
+ "name": "Allowed Protocol Mapper Types",
+ "providerId": "allowed-protocol-mappers",
+ "subType": "anonymous",
+ "subComponents": {},
+ "config": {
+ "allowed-protocol-mapper-types": [
+ "oidc-address-mapper",
+ "saml-user-attribute-mapper",
+ "saml-role-list-mapper",
+ "oidc-usermodel-property-mapper",
+ "oidc-full-name-mapper",
+ "oidc-sha256-pairwise-sub-mapper",
+ "oidc-usermodel-attribute-mapper",
+ "saml-user-property-mapper"
+ ]
+ }
+ },
+ {
+ "id": "8602688d-3424-4037-a0a0-12405b54f9f8",
+ "name": "Allowed Client Scopes",
+ "providerId": "allowed-client-templates",
+ "subType": "authenticated",
+ "subComponents": {},
+ "config": {
+ "allow-default-scopes": [
+ "true"
+ ]
+ }
+ },
+ {
+ "id": "d4a1a071-49de-47cf-b4f5-b70e7941f7a5",
+ "name": "Max Clients Limit",
+ "providerId": "max-clients",
+ "subType": "anonymous",
+ "subComponents": {},
+ "config": {
+ "max-clients": [
+ "200"
+ ]
+ }
+ },
+ {
+ "id": "2175cf38-7265-4e2d-9320-a31c15aef524",
+ "name": "Consent Required",
+ "providerId": "consent-required",
+ "subType": "anonymous",
+ "subComponents": {},
+ "config": {}
+ },
+ {
+ "id": "83c49007-1f60-4fdd-ae7a-5b9a966ded72",
+ "name": "Full Scope Disabled",
+ "providerId": "scope",
+ "subType": "anonymous",
+ "subComponents": {},
+ "config": {}
+ },
+ {
+ "id": "2a71ac63-e747-4cc3-9018-2a0f02c681f1",
+ "name": "Allowed Client Scopes",
+ "providerId": "allowed-client-templates",
+ "subType": "anonymous",
+ "subComponents": {},
+ "config": {
+ "allow-default-scopes": [
+ "true"
+ ]
+ }
+ },
+ {
+ "id": "593a59e1-9911-4eee-9803-f3091194d4e5",
+ "name": "Trusted Hosts",
+ "providerId": "trusted-hosts",
+ "subType": "anonymous",
+ "subComponents": {},
+ "config": {
+ "host-sending-registration-request-must-match": [
+ "true"
+ ],
+ "client-uris-must-match": [
+ "true"
+ ]
+ }
+ }
+ ],
+ "org.keycloak.userprofile.UserProfileProvider": [
+ {
+ "id": "918f023c-6942-4c7b-8026-a8fe4e037e0a",
+ "providerId": "declarative-user-profile",
+ "subComponents": {},
+ "config": {
+ "kc.user.profile.config": [
+ "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{},\"pattern\":{\"pattern\":\"^[a-z0-9]+[a-z0-9\\\\-]*[a-z0-9]+$\",\"error-message\":\"\"},\"length\":{\"min\":\"2\",\"max\":\"20\"}},\"annotations\":{},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]}},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]}},{\"name\":\"name\",\"displayName\":\"${name}\",\"permissions\":{\"edit\":[\"admin\",\"user\"],\"view\":[\"user\",\"admin\"]},\"annotations\":{},\"validations\":{\"person-name-prohibited-characters\":{\"error-message\":\"\"}}}],\"groups\":[]}"
+ ]
+ }
+ }
+ ],
+ "org.keycloak.keys.KeyProvider": [
+ {
+ "id": "f33d1c49-c3c5-4409-b56f-151464f12082",
+ "name": "aes-generated",
+ "providerId": "aes-generated",
+ "subComponents": {},
+ "config": {
+ "priority": [
+ "100"
+ ]
+ }
+ },
+ {
+ "id": "48e3e60b-2dc9-4adf-b343-0cb8a58c29d7",
+ "name": "hmac-generated",
+ "providerId": "hmac-generated",
+ "subComponents": {},
+ "config": {
+ "priority": [
+ "100"
+ ],
+ "algorithm": [
+ "HS256"
+ ]
+ }
+ },
+ {
+ "id": "fa16d8c9-f6fc-4139-a50d-d700ba7520fb",
+ "name": "rsa-generated",
+ "providerId": "rsa-generated",
+ "subComponents": {},
+ "config": {
+ "priority": [
+ "100"
+ ]
+ }
+ },
+ {
+ "id": "3ddadf69-a870-45d0-ada9-937ace566201",
+ "name": "rsa-enc-generated",
+ "providerId": "rsa-enc-generated",
+ "subComponents": {},
+ "config": {
+ "priority": [
+ "100"
+ ],
+ "algorithm": [
+ "RSA-OAEP"
+ ]
+ }
+ }
+ ]
+ },
+ "internationalizationEnabled": true,
+ "supportedLocales": [
+ "de",
+ "no",
+ "fi",
+ "ru",
+ "lt",
+ "lv",
+ "fr",
+ "hu",
+ "zh-CN",
+ "sk",
+ "ca",
+ "sv",
+ "pt-BR",
+ "el",
+ "en",
+ "it",
+ "es",
+ "cs",
+ "ar",
+ "ja",
+ "fa",
+ "pl",
+ "da",
+ "nl",
+ "tr"
+ ],
+ "defaultLocale": "en",
+ "authenticationFlows": [
+ {
+ "id": "837aceb1-a125-4512-9e1e-d9ac5d2e5b74",
+ "alias": "Account verification options",
+ "description": "Method with which to verity the existing account",
+ "providerId": "basic-flow",
+ "topLevel": false,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "idp-email-verification",
+ "authenticatorFlow": false,
+ "requirement": "ALTERNATIVE",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticatorFlow": true,
+ "requirement": "ALTERNATIVE",
+ "priority": 20,
+ "autheticatorFlow": true,
+ "flowAlias": "Verify Existing Account by Re-authentication",
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "0341e0fb-f68a-4873-8693-5580fb6a5a33",
+ "alias": "Browser - Conditional OTP",
+ "description": "Flow to determine if the OTP is required for the authentication",
+ "providerId": "basic-flow",
+ "topLevel": false,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "conditional-user-configured",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticator": "auth-otp-form",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 20,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "38bbee9b-ccc8-4a70-82b9-a557059a3195",
+ "alias": "Direct Grant - Conditional OTP",
+ "description": "Flow to determine if the OTP is required for the authentication",
+ "providerId": "basic-flow",
+ "topLevel": false,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "conditional-user-configured",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticator": "direct-grant-validate-otp",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 20,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "3f93705f-e378-4e0c-9e4f-44bd19fad478",
+ "alias": "First broker login - Conditional OTP",
+ "description": "Flow to determine if the OTP is required for the authentication",
+ "providerId": "basic-flow",
+ "topLevel": false,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "conditional-user-configured",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticator": "auth-otp-form",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 20,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "d2f35a8f-6963-466c-8fa9-e4750b7c33c2",
+ "alias": "Handle Existing Account",
+ "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider",
+ "providerId": "basic-flow",
+ "topLevel": false,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "idp-confirm-link",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticatorFlow": true,
+ "requirement": "REQUIRED",
+ "priority": 20,
+ "autheticatorFlow": true,
+ "flowAlias": "Account verification options",
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "19ccacc2-c55b-404e-8cd2-ec05a4433756",
+ "alias": "Reset - Conditional OTP",
+ "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.",
+ "providerId": "basic-flow",
+ "topLevel": false,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "conditional-user-configured",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticator": "reset-otp",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 20,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "cd95618e-8090-4310-a370-93cac5735394",
+ "alias": "User creation or linking",
+ "description": "Flow for the existing/non-existing user alternatives",
+ "providerId": "basic-flow",
+ "topLevel": false,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticatorConfig": "create unique user config",
+ "authenticator": "idp-create-user-if-unique",
+ "authenticatorFlow": false,
+ "requirement": "ALTERNATIVE",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticatorFlow": true,
+ "requirement": "ALTERNATIVE",
+ "priority": 20,
+ "autheticatorFlow": true,
+ "flowAlias": "Handle Existing Account",
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "a07df183-1404-4879-8527-4aca2a4f12b5",
+ "alias": "Verify Existing Account by Re-authentication",
+ "description": "Reauthentication of existing account",
+ "providerId": "basic-flow",
+ "topLevel": false,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "idp-username-password-form",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticatorFlow": true,
+ "requirement": "CONDITIONAL",
+ "priority": 20,
+ "autheticatorFlow": true,
+ "flowAlias": "First broker login - Conditional OTP",
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "4e81104d-c074-4978-b9d9-b3775b8db85d",
+ "alias": "browser",
+ "description": "browser based authentication",
+ "providerId": "basic-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "auth-cookie",
+ "authenticatorFlow": false,
+ "requirement": "ALTERNATIVE",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticator": "auth-spnego",
+ "authenticatorFlow": false,
+ "requirement": "DISABLED",
+ "priority": 20,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticator": "identity-provider-redirector",
+ "authenticatorFlow": false,
+ "requirement": "ALTERNATIVE",
+ "priority": 25,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticatorFlow": true,
+ "requirement": "ALTERNATIVE",
+ "priority": 30,
+ "autheticatorFlow": true,
+ "flowAlias": "forms",
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "8b1079fd-4d74-4dd9-9880-69f12816346e",
+ "alias": "clients",
+ "description": "Base authentication for clients",
+ "providerId": "client-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "client-secret",
+ "authenticatorFlow": false,
+ "requirement": "ALTERNATIVE",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticator": "client-jwt",
+ "authenticatorFlow": false,
+ "requirement": "ALTERNATIVE",
+ "priority": 20,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticator": "client-secret-jwt",
+ "authenticatorFlow": false,
+ "requirement": "ALTERNATIVE",
+ "priority": 30,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticator": "client-x509",
+ "authenticatorFlow": false,
+ "requirement": "ALTERNATIVE",
+ "priority": 40,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "427364f6-1881-4d72-8389-39ddafec8fa8",
+ "alias": "direct grant",
+ "description": "OpenID Connect Resource Owner Grant",
+ "providerId": "basic-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "direct-grant-validate-username",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticator": "direct-grant-validate-password",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 20,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticatorFlow": true,
+ "requirement": "CONDITIONAL",
+ "priority": 30,
+ "autheticatorFlow": true,
+ "flowAlias": "Direct Grant - Conditional OTP",
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "8c506173-2dab-47ee-b5f1-acbdb13e7b1c",
+ "alias": "docker auth",
+ "description": "Used by Docker clients to authenticate against the IDP",
+ "providerId": "basic-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "docker-http-basic-authenticator",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "6dd67a6e-bde9-4000-9fa9-38b985d935d5",
+ "alias": "first broker login",
+ "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account",
+ "providerId": "basic-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticatorConfig": "review profile config",
+ "authenticator": "idp-review-profile",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticatorFlow": true,
+ "requirement": "REQUIRED",
+ "priority": 20,
+ "autheticatorFlow": true,
+ "flowAlias": "User creation or linking",
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "80eb0f7c-6d8b-43cd-90e5-119d8e44824a",
+ "alias": "forms",
+ "description": "Username, password, otp and other auth forms.",
+ "providerId": "basic-flow",
+ "topLevel": false,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "auth-username-password-form",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticatorFlow": true,
+ "requirement": "CONDITIONAL",
+ "priority": 20,
+ "autheticatorFlow": true,
+ "flowAlias": "Browser - Conditional OTP",
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "9eb4e22d-39e7-48ff-b77e-757805d6987a",
+ "alias": "registration",
+ "description": "registration flow",
+ "providerId": "basic-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "registration-page-form",
+ "authenticatorFlow": true,
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "autheticatorFlow": true,
+ "flowAlias": "registration form",
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "34456905-10d7-4a57-9998-5df42f05b29a",
+ "alias": "registration form",
+ "description": "registration form",
+ "providerId": "form-flow",
+ "topLevel": false,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "registration-user-creation",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 20,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticator": "registration-password-action",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 50,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticator": "registration-recaptcha-action",
+ "authenticatorFlow": false,
+ "requirement": "DISABLED",
+ "priority": 60,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "0641d5d0-1d12-45e0-8520-7eb3c1ecb536",
+ "alias": "reset credentials",
+ "description": "Reset credentials for a user if they forgot their password or something",
+ "providerId": "basic-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "reset-credentials-choose-user",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticator": "reset-credential-email",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 20,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticator": "reset-password",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 30,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ },
+ {
+ "authenticatorFlow": true,
+ "requirement": "CONDITIONAL",
+ "priority": 40,
+ "autheticatorFlow": true,
+ "flowAlias": "Reset - Conditional OTP",
+ "userSetupAllowed": false
+ }
+ ]
+ },
+ {
+ "id": "f194e634-6df2-4b44-a3c6-461e94a3b8c3",
+ "alias": "saml ecp",
+ "description": "SAML ECP Profile Authentication Flow",
+ "providerId": "basic-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "http-basic-authenticator",
+ "authenticatorFlow": false,
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "autheticatorFlow": false,
+ "userSetupAllowed": false
+ }
+ ]
+ }
+ ],
+ "authenticatorConfig": [
+ {
+ "id": "292fb851-a707-4990-b103-fdf003c89f39",
+ "alias": "create unique user config",
+ "config": {
+ "require.password.update.after.registration": "false"
+ }
+ },
+ {
+ "id": "4d7896e8-a936-48d0-aff5-9e3746361daa",
+ "alias": "review profile config",
+ "config": {
+ "update.profile.on.first.login": "missing"
+ }
+ }
+ ],
+ "requiredActions": [
+ {
+ "alias": "CONFIGURE_TOTP",
+ "name": "Configure OTP",
+ "providerId": "CONFIGURE_TOTP",
+ "enabled": true,
+ "defaultAction": false,
+ "priority": 10,
+ "config": {}
+ },
+ {
+ "alias": "TERMS_AND_CONDITIONS",
+ "name": "Terms and Conditions",
+ "providerId": "TERMS_AND_CONDITIONS",
+ "enabled": false,
+ "defaultAction": false,
+ "priority": 20,
+ "config": {}
+ },
+ {
+ "alias": "UPDATE_PASSWORD",
+ "name": "Update Password",
+ "providerId": "UPDATE_PASSWORD",
+ "enabled": true,
+ "defaultAction": false,
+ "priority": 30,
+ "config": {}
+ },
+ {
+ "alias": "UPDATE_PROFILE",
+ "name": "Update Profile",
+ "providerId": "UPDATE_PROFILE",
+ "enabled": true,
+ "defaultAction": false,
+ "priority": 40,
+ "config": {}
+ },
+ {
+ "alias": "VERIFY_EMAIL",
+ "name": "Verify Email",
+ "providerId": "VERIFY_EMAIL",
+ "enabled": true,
+ "defaultAction": false,
+ "priority": 50,
+ "config": {}
+ },
+ {
+ "alias": "delete_account",
+ "name": "Delete Account",
+ "providerId": "delete_account",
+ "enabled": true,
+ "defaultAction": false,
+ "priority": 60,
+ "config": {}
+ },
+ {
+ "alias": "webauthn-register",
+ "name": "Webauthn Register",
+ "providerId": "webauthn-register",
+ "enabled": true,
+ "defaultAction": false,
+ "priority": 70,
+ "config": {}
+ },
+ {
+ "alias": "webauthn-register-passwordless",
+ "name": "Webauthn Register Passwordless",
+ "providerId": "webauthn-register-passwordless",
+ "enabled": true,
+ "defaultAction": false,
+ "priority": 80,
+ "config": {}
+ },
+ {
+ "alias": "update_user_locale",
+ "name": "Update User Locale",
+ "providerId": "update_user_locale",
+ "enabled": true,
+ "defaultAction": false,
+ "priority": 1000,
+ "config": {}
+ }
+ ],
+ "browserFlow": "browser",
+ "registrationFlow": "registration",
+ "directGrantFlow": "direct grant",
+ "resetCredentialsFlow": "reset credentials",
+ "clientAuthenticationFlow": "clients",
+ "dockerAuthenticationFlow": "docker auth",
+ "attributes": {
+ "cibaBackchannelTokenDeliveryMode": "poll",
+ "cibaAuthRequestedUserHint": "login_hint",
+ "clientOfflineSessionMaxLifespan": "0",
+ "oauth2DevicePollingInterval": "5",
+ "clientSessionIdleTimeout": "0",
+ "userProfileEnabled": "true",
+ "clientOfflineSessionIdleTimeout": "0",
+ "cibaInterval": "5",
+ "realmReusableOtpCode": "false",
+ "cibaExpiresIn": "120",
+ "oauth2DeviceCodeLifespan": "600",
+ "parRequestUriLifespan": "60",
+ "clientSessionMaxLifespan": "0",
+ "frontendUrl": "",
+ "acr.loa.map": "{}",
+ "adminEventsExpiration": "5400"
+ },
+ "keycloakVersion": "23.0.0",
+ "userManagedAccessAllowed": false,
+ "clientProfiles": {
+ "profiles": []
+ },
+ "clientPolicies": {
+ "policies": []
+ }
+}
diff --git a/conf/nginx-docker/nginx.conf b/conf/nginx-docker/nginx.conf
index 6929179121f5d..ccfbcbbff3567 100644
--- a/conf/nginx-docker/nginx.conf
+++ b/conf/nginx-docker/nginx.conf
@@ -131,3 +131,27 @@ server {
proxy_pass http://$backend;
}
}
+
+server {
+ listen 80;
+ listen [::]:80;
+
+ server_name auth.${PRODUCT_OPENER_DOMAIN};
+
+ # logs location
+ access_log /var/log/nginx/auth-off-access.log;
+ error_log /var/log/nginx/auth-off-error.log;
+
+ gzip on;
+ gzip_min_length 1000;
+
+ # this is the internal Docker DNS, cache only for 30s
+ resolver 127.0.0.11 valid=30s;
+ location / {
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ set $keycloak keycloak;
+ proxy_pass http://$keycloak:8080;
+ }
+}
diff --git a/cpanfile b/cpanfile
index d7bc18fcf1064..065d261d2ef33 100644
--- a/cpanfile
+++ b/cpanfile
@@ -16,7 +16,6 @@ requires 'JSON::PP'; # libjson-pp-perl
requires 'Cpanel::JSON::XS'; # libcpanel-json-xs-perl - fast parsing
requires 'JSON::MaybeXS'; # libjson-maybexs-perl
requires 'Clone'; # libclone-perl
-requires 'Crypt::PasswdMD5'; # libcrypt-passwdmd5-perl
requires 'Encode::Detect'; # libencode-detect-perl
requires 'Barcode::ZBar'; # libbarcode-zbar-perl
requires 'XML::FeedPP'; # libxml-feedpp-perl
@@ -38,7 +37,7 @@ requires 'GeoIP2', '>= 2.006002, < 3.0'; # libgeoip2-perl, deps: libdata-validat
requires 'Email::Valid', '>= 1.202, < 2.0'; # libemail-valid-perl
requires 'Path::Tiny', '>= 0.118'; # libpath-tiny-perl
requires 'XML::RPC', '== 2'; # libxml-rpc-fast-perl
-
+requires 'AnyEvent::RipeRedis'; # libanyevent-redis-perl
# Probably not available as Debian/Ubuntu packages
requires 'MongoDB', '>= 2.2.2, < 2.3'; # libmongodb-perl has 1.8.1/2.0.3 vs 2.2.2. deps: libauthen-sasl-saslprep-perl, libbson-perl, libauthen-scram-perl, libclass-xsaccessor-perl, libdigest-hmac-perl, libsafe-isa-perl, libconfig-autoconf-perl, libpath-tiny-perl
@@ -50,7 +49,6 @@ requires 'Image::OCR::Tesseract'; # deps: libfile-find-rule-perl
requires 'DateTime', '>= 1.54, < 2.0'; # libdatetime-perl has 1.46. deps: libclass-singleton-perl
requires 'DateTime::Locale', '>= 1.32, < 2.0'; # libdatetime-locale-perl has 1.17. deps: libfile-sharedir-install-perl
requires 'DateTime::Format::ISO8601'; # libdatetime-format-iso8601-perl
-requires 'Crypt::ScryptKDF';
requires 'Locale::Maketext::Lexicon::Getcontext', '>= 0.05'; # deps: liblocale-maketext-lexicon-perl
requires 'CLDR::Number::Format::Decimal';
requires 'CLDR::Number::Format::Percent';
@@ -68,7 +66,6 @@ requires 'JSON::Create';
requires 'JSON::Parse';
requires 'Data::DeepAccess';
requires 'XML::XML2JSON';
-requires 'Redis';
requires 'Digest::SHA1';
requires 'Data::Difference';
requires 'Data::Compare';
@@ -86,6 +83,8 @@ requires 'Log::Any::Adapter::Log4perl', '>= 0.09'; # liblog-any-adapter-log4perl
# Retry
requires 'Action::CircuitBreaker';
requires 'Action::Retry'; # deps: libmath-fibonacci-perl
+requires 'LWP::UserAgent::Plugin';
+requires 'LWP::UserAgent::Plugin::Retry';
# AnyEvent
requires 'AnyEvent';
@@ -104,6 +103,10 @@ requires 'Imager::File::JPEG';
requires 'Imager::File::PNG';
requires 'Imager::File::WEBP';
+# OIDC / OAuth
+requires 'OIDC::Lite';
+requires 'Crypt::JWT';
+
# To dynamically load Config_*.pm modules
requires 'Module::Load';
diff --git a/docker-compose.yml b/docker-compose.yml
index 66546f4bfd182..84a7e357eed2e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -35,6 +35,12 @@ x-backend-conf: &backend-conf
- ODOO_CRM_DB
- ODOO_CRM_USER
- ODOO_CRM_PASSWORD
+ - KEYCLOAK_BASE_URL
+ - KEYCLOAK_BACKCHANNEL_BASE_URL
+ - KEYCLOAK_REALM_NAME
+ - PRODUCT_OPENER_OIDC_CLIENT_ID
+ - PRODUCT_OPENER_OIDC_CLIENT_SECRET
+ - PRODUCT_OPENER_OIDC_DISCOVERY_ENDPOINT
depends_on:
- memcached
volumes:
@@ -104,8 +110,12 @@ services:
- "$MINION_QUEUE"
incron:
<<: *backend-conf
- # This service watch for new images to trigger ocr and robotoff processing
+ # This service watches for new images to trigger ocr and robotoff processing
command: ["perl", "scripts/run_cloud_vision_ocr.pl", "/mnt/podata/new_images"]
+ redis-listener:
+ <<: *backend-conf
+ # This service watches for new events on a redis stream to delete users
+ command: ["perl", "scripts/listen_to_redis_stream.pl"]
frontend:
image: ghcr.io/openfoodfacts/openfoodfacts-server/frontend:${TAG}
depends_on:
diff --git a/docker/dev.yml b/docker/dev.yml
index 4a2999d6c238d..2ab41d07738e4 100644
--- a/docker/dev.yml
+++ b/docker/dev.yml
@@ -27,6 +27,9 @@ x-backend-conf: &backend-conf
# e.g. when developing with openfoodfacts-query
extra_hosts:
- "host.docker.internal:host-gateway"
+ - "auth.openfoodfacts.localhost:host-gateway"
+ networks:
+ default:
x-minion-db-network: &minion-db-network
networks:
@@ -42,6 +45,7 @@ services:
backend:
<<: [*backend-conf, *minion-db-network]
incron: *backend-conf
+ redis-listener: *backend-conf
minion:
<<: [*backend-conf, *minion-db-network]
# in dev we want to use watch assets and recompile on the fly
@@ -87,9 +91,21 @@ services:
aliases:
# trick: make it possible for robotoff to reach it internally,
# using localhost domain
- - world.openfoodfacts.localhost
- - static.openfoodfacts.localhost
- - images.openfoodfacts.localhost
+ - world.${PRODUCT_OPENER_DOMAIN}
+ - static.${PRODUCT_OPENER_DOMAIN}
+ - images.${PRODUCT_OPENER_DOMAIN}
+ - fr.${PRODUCT_OPENER_DOMAIN}
+ - world-be.${PRODUCT_OPENER_DOMAIN}
+ - world-de.${PRODUCT_OPENER_DOMAIN}
+ - world-it.${PRODUCT_OPENER_DOMAIN}
+ - es-it.${PRODUCT_OPENER_DOMAIN}
+ - ch-it.${PRODUCT_OPENER_DOMAIN}
+ - ssl-api.${PRODUCT_OPENER_DOMAIN}
+ - fr.pro.${PRODUCT_OPENER_DOMAIN}
+ - world.pro.${PRODUCT_OPENER_DOMAIN}
+ - auth.${PRODUCT_OPENER_DOMAIN}
+ - es.${PRODUCT_OPENER_DOMAIN}
+ - be-fr.${PRODUCT_OPENER_DOMAIN}
volumes:
product_images:
diff --git a/docker/integration-test.yml b/docker/integration-test.yml
new file mode 100644
index 0000000000000..60c45293e3163
--- /dev/null
+++ b/docker/integration-test.yml
@@ -0,0 +1,24 @@
+include:
+ - ${DEPS_DIR}/openfoodfacts-shared-services/docker-compose.yml
+ - ${DEPS_DIR}/openfoodfacts-auth/docker-compose.yml
+
+services:
+ backend:
+ depends_on:
+ postgres:
+ condition: service_started
+ mongodb:
+ condition: service_started
+ dynamicfront:
+ condition: service_started
+ incron:
+ condition: service_started
+ minion:
+ condition: service_started
+ redis:
+ condition: service_started
+ redis-listener:
+ condition: service_started
+ keycloak:
+ # Keycloak takes a while to start so need to wait until it is healthy
+ condition: service_healthy
diff --git a/docker/prod.yml b/docker/prod.yml
index 77b854d7a9914..12d4cbc43bc82 100644
--- a/docker/prod.yml
+++ b/docker/prod.yml
@@ -16,6 +16,10 @@ services:
restart: always
networks:
- webnet
+ redis-listener:
+ restart: always
+ networks:
+ - webnet
minion:
restart: always
networks:
diff --git a/docker/run.yml b/docker/run.yml
index e8a686abe57ac..93305f9d8366d 100644
--- a/docker/run.yml
+++ b/docker/run.yml
@@ -9,6 +9,10 @@ services:
networks:
# Needed to access MongoDB and Redis
shared_network:
+ redis-listener:
+ networks:
+ # Needed to access MongoDB and Redis
+ shared_network:
networks:
# This network allows access to shared services like MongoDB and Redis
diff --git a/lib/ProductOpener/API.pm b/lib/ProductOpener/API.pm
index 7f4ac51ed6714..f677ab194418d 100644
--- a/lib/ProductOpener/API.pm
+++ b/lib/ProductOpener/API.pm
@@ -1,7 +1,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -53,6 +53,7 @@ BEGIN {
&normalize_requested_code
&customize_response_for_product
&check_user_permission
+ &process_auth_header
); # symbols to export on request
%EXPORT_TAGS = (all => [@EXPORT_OK]);
}
@@ -62,6 +63,7 @@ use vars @EXPORT_OK;
use ProductOpener::Config qw/:all/;
use ProductOpener::Display qw/:all/;
use ProductOpener::HTTP qw/write_cors_headers/;
+use ProductOpener::Auth qw/:all/;
use ProductOpener::Users qw/:all/;
use ProductOpener::Lang qw/$lc lang_in_other_lc/;
use ProductOpener::Products qw/normalize_code_with_gs1_ai product_name_brand_quantity/;
@@ -74,6 +76,8 @@ use ProductOpener::Ecoscore qw/localize_ecoscore/;
use ProductOpener::Packaging qw/%packaging_taxonomies/;
use ProductOpener::Permissions qw/has_permission/;
use ProductOpener::GeoIP qw/get_country_for_ip_api/;
+use ProductOpener::Paths qw/:all/;
+use ProductOpener::Store qw/:all/;
use ProductOpener::APIProductRead qw/read_product_api/;
use ProductOpener::APIProductWrite qw/write_product_api/;
@@ -83,7 +87,7 @@ use ProductOpener::APITagRead qw/read_tag_api/;
use ProductOpener::APITaxonomySuggestions qw/taxonomy_suggestions_api/;
use ProductOpener::ProductsFeatures qw(feature_enabled);
-use CGI qw(header);
+use CGI qw/:cgi :form escapeHTML/;
use Apache2::RequestIO();
use Apache2::RequestRec();
use JSON::MaybeXS;
@@ -908,4 +912,103 @@ sub check_user_permission ($request_ref, $response_ref, $permission) {
return $error;
}
+=head2 process_auth_header ( $request_ref, $r )
+
+Using the Authorization HTTP header, check if we have a valid user.
+
+=head3 Parameters
+
+=head4 $request_ref (input)
+
+Reference to the request object.
+
+=head4 $r (input)
+
+Reference to the Apache2 request object
+
+=head3 Return value
+
+1 if the user has been signed in, -1 if the Bearer token was invalid, 0 otherwise.
+
+=cut
+
+sub process_auth_header ($request_ref, $r) {
+ my $token = _read_auth_header($request_ref, $r);
+ unless ($token) {
+ return 0;
+ }
+
+ my $access_token;
+ # verify token using JWKS (see Auth.pm)
+ eval {$access_token = verify_access_token($token);};
+ my $error = $@;
+ if ($error) {
+ $log->info('Access token invalid', {token => $token}) if $log->is_info();
+ }
+
+ unless ($access_token) {
+ add_error(
+ $request_ref->{api_response},
+ {
+ message => {id => 'invalid_token'},
+ impact => {id => 'failure'},
+ }
+ );
+ return -1;
+ }
+
+ $request_ref->{access_token} = $access_token;
+ my $user_id = get_user_id_using_token($access_token, $request_ref);
+ unless (defined $user_id) {
+ $log->info('User not found and not created') if $log->is_info();
+ display_error_and_exit($request_ref, 'Internal error', 500);
+ }
+
+ my $user_ref = retrieve_user($user_id);
+ unless (defined $user_ref) {
+ $log->info('User not found', {user_id => $user_id}) if $log->is_info();
+ display_error_and_exit($request_ref, 'Internal error', 500);
+ }
+
+ $log->debug('user_id found', {user_id => $user_id}) if $log->is_debug();
+
+ my $user_session = open_user_session($user_ref, undef, undef, $access_token->{access_token},
+ undef, $access_token->{id_token}, $request_ref);
+ param('user_id', $user_id);
+ param('user_session', $user_session);
+ init_user($request_ref);
+
+ return 1;
+}
+
+=head2 _read_auth_header ( $request_ref, $r )
+
+Using the Authorization HTTP header, check if it looks like a
+Bearer token, and if it does, copy it to the request_ref
+
+=head3 Parameters
+
+=head4 $request_ref (input)
+
+Reference to the request object.
+
+=head4 $r (input)
+
+Reference to the Apache2 request object
+
+=head3 Return value
+
+None
+
+=cut
+
+sub _read_auth_header ($request_ref, $r) {
+ my $authorization = $r->headers_in->{Authorization};
+ if ((defined $authorization) and ($authorization =~ /^Bearer (?.+)$/)) {
+ return $+{token};
+ }
+
+ return;
+}
+
1;
diff --git a/lib/ProductOpener/APIProductWrite.pm b/lib/ProductOpener/APIProductWrite.pm
index abe224347b228..b5c71de61ece9 100644
--- a/lib/ProductOpener/APIProductWrite.pm
+++ b/lib/ProductOpener/APIProductWrite.pm
@@ -1,7 +1,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -54,6 +54,7 @@ use ProductOpener::Packaging
qw/add_or_combine_packaging_component_data get_checked_and_taxonomized_packaging_component_data/;
use ProductOpener::Text qw/remove_tags_and_quote/;
use ProductOpener::Tags qw/%language_fields %writable_tags_fields add_tags_to_field compute_field_tags/;
+use ProductOpener::Auth qw/get_azp/;
use Encode;
@@ -427,7 +428,7 @@ sub write_product_api ($request_ref) {
# The product does not exist yet, or the requested code is "test"
if (not defined $product_ref) {
- $product_ref = init_product($User_id, $Org_id, $code, $country);
+ $product_ref = init_product($User_id, $Org_id, $code, $country, get_azp($request_ref->{access_token}));
$product_ref->{interface_version_created} = "20221102/api/v3";
}
@@ -474,7 +475,7 @@ sub write_product_api ($request_ref) {
# Save the product
if ($code ne "test") {
my $comment = $request_body_ref->{comment} || "API v3";
- store_product($User_id, $product_ref, $comment);
+ store_product($User_id, $product_ref, $comment, get_azp($request_ref->{access_token}));
}
# Select / compute only the fields requested by the caller, default to updated fields
diff --git a/lib/ProductOpener/APITest.pm b/lib/ProductOpener/APITest.pm
index 73c1805efe698..fc795281c7cc7 100644
--- a/lib/ProductOpener/APITest.pm
+++ b/lib/ProductOpener/APITest.pm
@@ -1,7 +1,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -37,6 +37,7 @@ BEGIN {
&construct_test_url
&create_user
&edit_user
+ &create_user_in_keycloak
&edit_product
&get_page
&html_displays_error
@@ -64,6 +65,7 @@ use ProductOpener::Test qw/:all/;
use ProductOpener::Mail qw/$LOG_EMAIL_START $LOG_EMAIL_END/;
use ProductOpener::Store qw/store retrieve/;
use ProductOpener::Producers qw/get_minion/;
+use ProductOpener::Config qw/%oidc_options/;
use Test2::V0;
use Data::Dumper;
@@ -86,6 +88,33 @@ no warnings qw(experimental::signatures);
my $TEST_MAIN_DOMAIN = "openfoodfacts.localhost";
my $TEST_WEBSITE_URL = "http://world." . $TEST_MAIN_DOMAIN;
+=head2 wait_auth()
+
+Wait for authentication server to be ready.
+It's important because the application might fail because of that
+
+=cut
+
+sub wait_auth() {
+
+ # simply try to access front page
+ my $count = 0;
+ my $ua = new_client();
+ my $target_url = construct_test_url("");
+ while (1) {
+ my $response = $ua->get($oidc_options{discovery_endpoint});
+ last if $response->is_success;
+ sleep 1;
+ $count++;
+ if (($count % 3) == 0) {
+ print("Waiting for auth to be ready since more than $count seconds...\n");
+ diag explain({url => $target_url, status => $response->code, response => $response});
+ }
+ confess("Waited too much for auth") if $count > 60;
+ }
+ return;
+}
+
=head2 wait_dynamic_front()
Wait for dynamic_front to be ready.
@@ -138,7 +167,7 @@ sub wait_server() {
=head2 wait_application_ready()
-Wait for server and dynamic front to be ready.
+Wait for server, dynamic front, and authentication server to be ready.
Run this at the beginning of every integration test
=cut
@@ -146,6 +175,7 @@ Run this at the beginning of every integration test
sub wait_application_ready() {
wait_server();
wait_dynamic_front();
+ wait_auth();
return;
}
@@ -225,6 +255,58 @@ sub login ($ua, $user_id, $password) {
return $response;
}
+=head2 create_user_in_keycloak($user_ref)
+
+Call API to create a user in Keycloak
+without creating them in ProductOpener, too.
+As create_user uses the ProductOpener API, this
+is useful for testing the Keycloak API on it's own.
+
+=head3 Arguments
+
+=head4 $user_ref - fields
+
+=cut
+
+sub create_user_in_keycloak ($user_ref) {
+
+ my $credential = {
+ type => 'password',
+ value => $user_ref->{password},
+ temporary => $JSON::false
+ };
+
+ my $keycloak_user_ref = {
+ email => $user_ref->{email},
+ emailVerified => $user_ref->{email_verified} ? $JSON::PP::true : $JSON::PP::true,
+ enabled => $JSON::PP::true,
+ username => $user_ref->{userid},
+ credentials => [$credential],
+ attributes => [
+ name => [$user_ref->{name}],
+ locale => [$user_ref->{initial_lc}],
+ country => [$user_ref->{initial_cc}],
+ ]
+ };
+
+ my $json = encode_json($keycloak_user_ref);
+
+ my $keycloak = ProductOpener::Keycloak->new();
+ my $request_token = $keycloak->get_or_refresh_token();
+ my $create_user_request = HTTP::Request->new(POST => $keycloak->{users_endpoint});
+ $create_user_request->header('Content-Type' => 'application/json');
+ $create_user_request->header(
+ 'Authorization' => $request_token->{token_type} . ' ' . $request_token->{access_token});
+ $create_user_request->content($json);
+ my $new_user_response = LWP::UserAgent::Plugin->new->request($create_user_request);
+
+ unless ($new_user_response->is_success) {
+ return 0;
+ }
+
+ return 1;
+}
+
=head2 get_page ($ua, $url)
Get a page of the app
@@ -634,14 +716,23 @@ sub check_request_response ($test_ref, $response, $test_id, $test_dir, $expected
return;
}
-sub execute_api_tests ($file, $tests_ref, $ua = undef) {
+sub execute_api_tests ($file, $tests_ref, $ua = undef, $reuse_ua = 1) {
+
+ if ((defined $ua) and (not($reuse_ua))) {
+ confess('Error in API test setup for ' . $file . ': $ua was passed but $reuse_ua was not set to 1');
+ return;
+ }
my ($test_id, $test_dir, $expected_result_dir, $update_expected_results) = (init_expected_results($file));
- $ua = $ua // LWP::UserAgent->new();
+ $ua = $ua // new_client();
foreach my $test_ref (@$tests_ref) {
+ if (not($reuse_ua)) {
+ $ua = new_client();
+ }
+
my $response = execute_request($test_ref, $ua);
check_request_response($test_ref, $response, $test_id, $test_dir, $expected_result_dir,
diff --git a/lib/ProductOpener/Auth.pm b/lib/ProductOpener/Auth.pm
new file mode 100644
index 0000000000000..89b7fd6c4b162
--- /dev/null
+++ b/lib/ProductOpener/Auth.pm
@@ -0,0 +1,831 @@
+# This file is part of Product Opener.
+#
+# Product Opener
+# Copyright (C) 2011-2024 Association Open Food Facts
+# Contact: contact@openfoodfacts.org
+# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
+#
+# Product Opener is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+=head1 NAME
+
+ProductOpener::Auth - Perl module for OpenID Connect (OIDC) and Keycloak authentication
+
+=head1 DESCRIPTION
+
+This Perl module provides functions for user authentication, token verification, and access to protected resources using OpenID Connect (OIDC) and Keycloak.
+
+=cut
+
+package ProductOpener::Auth;
+
+use ProductOpener::PerlStandards;
+use Exporter qw< import >;
+
+use Log::Any qw($log);
+
+BEGIN {
+ use vars qw(@ISA @EXPORT_OK %EXPORT_TAGS);
+ @EXPORT_OK = qw(
+ &access_to_protected_resource
+ &signin_callback
+ &signout_callback
+ &password_signin
+ &verify_access_token
+ &verify_id_token
+ &get_user_id_using_token
+ &get_token_using_client_credentials
+ &get_token_using_password_credentials
+ &get_azp
+ &write_auth_deprecated_headers
+ &start_signout
+
+ $oidc_discover_document
+ $jwks
+ ); # symbols to export on request
+ %EXPORT_TAGS = (all => [@EXPORT_OK]);
+}
+
+use vars @EXPORT_OK;
+
+use ProductOpener::Config qw/:all/;
+use ProductOpener::Display qw/$subdomain $formatted_subdomain single_param redirect_to_url display_error_and_exit/;
+use ProductOpener::URL qw/get_cookie_domain format_subdomain/;
+use ProductOpener::Users qw/$User_id retrieve_user store_user generate_token init_user open_user_session/;
+use ProductOpener::Lang qw/$lc/;
+
+use OIDC::Lite;
+use OIDC::Lite::Client::WebServer;
+use OIDC::Lite::Model::IDToken;
+use Crypt::JWT qw(decode_jwt);
+
+use CGI qw/:cgi :form escapeHTML/;
+use Apache2::RequestIO();
+use Apache2::RequestRec();
+use MIME::Base64 qw(decode_base64);
+use JSON::PP;
+use Data::DeepAccess qw(deep_get);
+use Storable qw(dclone);
+use Encode;
+use LWP::UserAgent;
+use LWP::UserAgent::Plugin 'Retry';
+use HTTP::Request;
+use URI::Escape::XS qw/uri_escape/;
+
+# Initialize some constants
+
+my $cookie_name = 'oidc';
+my $cookie_domain = get_cookie_domain();
+
+my $callback_uri = format_subdomain('world') . '/cgi/oidc_signin_callback.pl';
+my $signout_callback_uri = format_subdomain('world') . '/cgi/oidc_signout_callback.pl';
+
+my $client = undef;
+
+=head2 start_authorize($request_ref)
+
+Initiates the authorization process by redirecting the user to the authorization page.
+
+=head3 Arguments
+
+=head4 A reference to a hash containing request information. $request_ref
+
+=head3 Return Values
+
+None
+
+=cut
+
+sub start_authorize ($request_ref) {
+ # random private token to identify the sign-in process
+ my $nonce = generate_token(64);
+ my $return_url = $request_ref->{return_url};
+ if ( (not $return_url)
+ or (not($return_url =~ /^https?:\/\/$subdomain\.$server_domain/)))
+ {
+ $return_url = $formatted_subdomain;
+ }
+
+ # get main OIDC client (keycloak)
+ my $current_client = _get_client();
+ my $redirect_url = $current_client->uri_to_redirect(
+ redirect_uri => $callback_uri,
+ scope => q{openid profile offline_access},
+ state => $nonce,
+ )
+ . '&ui_locales='
+ . uri_escape($lc) . '&lc='
+ . uri_escape($lc) . '&cc='
+ . uri_escape($request_ref->{cc});
+
+ $request_ref->{cookie} = generate_oidc_cookie($nonce, $return_url);
+ redirect_to_url($request_ref, 302, $redirect_url);
+ return;
+}
+
+=head2 signin_callback($request_ref)
+
+Handles the callback after successful authentication, verifies the ID token, and creates or retrieves the user's information.
+
+=head3 Arguments
+
+=head4 A reference to a hash containing request information. $request_ref
+
+=head3 Return values
+
+The return URL after successful authentication.
+
+=cut
+
+sub signin_callback ($request_ref) {
+ if (not(defined cookie($cookie_name))) {
+ display_error_and_exit(lang('oidc_signin_no_cookie'), 400);
+ return;
+ }
+
+ my $code = single_param('code');
+ my $state = single_param('state');
+ my $time = time;
+ my $current_client = _get_client();
+ # access token shall have been set by OIDC service, get it
+ my $access_token = $current_client->get_access_token(
+ code => $code,
+ redirect_uri => $callback_uri,
+ ) or display_error_and_exit($request_ref, $current_client->errstr, 500);
+ $log->info('got access token during callback', {access_token => $access_token}) if $log->is_info();
+
+ my %cookie_ref = cookie($cookie_name);
+ # verify we are in the right sign-in process, thanks to the randomly generated token
+ my $nonce = $cookie_ref{'nonce'};
+ if (not($state eq $nonce)) {
+ $log->info('unexpected nonce', {nonce => $nonce, expected_nonce => $state}) if $log->is_info();
+ display_error_and_exit($request_ref, 'Invalid Nonce during OIDC login', 500);
+ }
+
+ # validation against JWKS
+ my $id_token = verify_id_token($access_token->id_token);
+ unless ($id_token) {
+ $log->info('id token did not verify') if $log->is_info();
+ display_error_and_exit($request_ref, 'Authentication error', 401);
+ }
+
+ my $user_id = get_user_id_using_token($id_token, $request_ref);
+ unless (defined $user_id) {
+ $log->info('User not found and not created') if $log->is_info();
+ display_error_and_exit($request_ref, 'Internal error', 500);
+ }
+
+ my $user_ref = retrieve_user($user_id);
+ unless ($user_ref) {
+ $log->info('User not found', {user_id => $user_id}) if $log->is_info();
+ display_error_and_exit($request_ref, 'Internal error', 500);
+ }
+
+ $log->debug('user found', {user_ref => $user_ref}) if $log->is_debug();
+ my $user_session = open_user_session(
+ $user_ref,
+ $access_token->{refresh_token},
+ $time + $access_token->{refresh_expires_in},
+ $access_token->{access_token},
+ $time + $access_token->{expires_in},
+ $access_token->{id_token}, $request_ref
+ );
+ # add as apache parameter for now (should better be in request_ref)
+ param('user_id', $user_id);
+ param('user_session', $user_session);
+ init_user($request_ref);
+
+ return $cookie_ref{'return_url'};
+}
+
+=head2 password_signin($username, $password, $request_ref)
+
+Signs in the user with a username and password, and returns the user's ID, refresh token, refresh token expiration time, access token, and access token expiration time.
+
+We support this to enable passing user and password in the request json. This is a legacy way of doing.
+
+=head3 Arguments
+
+=head4 The username for password-based authentication. $username
+
+=head4 The password for password-based authentication. $password
+
+=head3 Return Values
+
+A list containing the user's ID, refresh token, refresh token expiration time, access token, access token expiration time, and the ID token
+
+=cut
+
+sub password_signin ($username, $password, $request_ref) {
+ unless ($username and $password) {
+ return;
+ }
+
+ my $time = time;
+ my $access_token = get_token_using_password_credentials($username, $password);
+ unless ($access_token) {
+ return;
+ }
+
+ my $id_token = verify_id_token($access_token->{id_token});
+ unless ($id_token) {
+ $log->info('id token did not verify') if $log->is_info();
+ return;
+ }
+
+ my $user_id = get_user_id_using_token($id_token, $request_ref);
+ $log->debug('user_id found', {user_id => $user_id}) if $log->is_debug();
+ return (
+ $user_id,
+ $access_token->{refresh_token},
+ # use absolute time instead of relative time
+ $time + $access_token->{refresh_expires_in},
+ $access_token->{access_token},
+ # use absolute time instead of relative time
+ $time + $access_token->{expires_in},
+ $id_token
+ );
+}
+
+=head2 get_user_id_using_token ($id_token, , $request_ref, $require_verified_email)
+
+Extract the user id from the OIDC identification token (which contains an email).
+
+It verifies that the email is a verified email before proceeding.
+
+If the user properties file does not yet exists, it create it.
+
+=head3 Arguments
+
+=head4 hash ref $id_token
+
+The OIDC identification token information
+
+=head4 boolean $require_verified_email
+
+If true, the email must be verified before proceeding.
+
+=head3 Return Value
+
+The userid as a string
+
+=cut
+
+sub get_user_id_using_token ($id_token, $request_ref, $require_verified_email = 0) {
+ if ($require_verified_email and (not($id_token->{'email_verified'} eq $JSON::PP::true))) {
+ $log->info('User email is not verified.', {email => $id_token->{'email'}}) if $log->is_info();
+ return;
+ }
+
+ my $user_id = $id_token->{'preferred_username'};
+ my $user_ref = retrieve_user($user_id);
+ unless ($user_ref) {
+ $log->info('User not found', {user_id => $user_id}) if $log->is_info();
+ $user_ref = {userid => $user_id};
+ }
+
+ # Update duplicated information from Keycloak
+ $user_ref->{name} = $id_token->{'name'} // $user_id;
+ $user_ref->{email} = $id_token->{'email'};
+
+ # Make sure initial information is set (user may have been created by Redis)
+ defined $user_ref->{registered_t} or $user_ref->{registered_t} = time();
+ defined $user_ref->{last_login_t} or $user_ref->{last_login_t} = time();
+ defined $user_ref->{ip} or $user_ref->{ip} = remote_addr();
+ defined $user_ref->{initial_lc} or $user_ref->{initial_lc} = $lc;
+ defined $user_ref->{initial_cc} or $user_ref->{initial_cc} = $request_ref->{cc};
+ defined $user_ref->{initial_user_agent} or $user_ref->{initial_user_agent} = user_agent();
+
+ store_user($user_ref);
+
+ return $user_ref->{userid};
+}
+
+=head2 refresh_access_token ($id_token)
+
+Refreshes the access token using the OIDC client.
+
+Access token have a limited life span but can be refreshed
+
+=head3 Arguments
+
+=head4 hash ref $refresh_token
+
+OIDC refresh token
+
+=head3 Return Value
+
+A list containing the user's ID, new refresh token, refresh token expiration time, new access token, and access token expiration time.
+
+=cut
+
+sub refresh_access_token ($refresh_token) {
+ my $time = time;
+ my $current_client = _get_client();
+ my $access_token = $current_client->refresh_access_token(refresh_token => $refresh_token,)
+ or die $current_client->errstr;
+
+ $log->info('refreshed access token', {access_token => $access_token}) if $log->is_info();
+ return (
+ $access_token->{refresh_token}, $time + $access_token->{refresh_expires_in},
+ $access_token->{access_token}, $time + $access_token->{expires_in}
+ );
+}
+
+=head2 access_to_protected_resource ($request_ref)
+
+This method insure a user is authenticated before proceeding to a specific page.
+
+If user is not authenticated, or his access token can't be refreshed,
+it will be redirected to signin process.
+
+=head3 Arguments
+
+=head4 A reference to a hash containing request information. $request_ref
+
+=head3 Return Values
+
+None
+
+=cut
+
+sub access_to_protected_resource ($request_ref) {
+ unless ($User_id) {
+ start_authorize($request_ref);
+ return;
+ }
+
+ my $access_token = $request_ref->{access_token};
+ my $refresh_expires_at = $request_ref->{refresh_expires_at};
+ my $refresh_token = $request_ref->{refresh_token};
+ my $access_expires_at = $request_ref->{access_expires_at};
+
+ unless ($access_token) {
+ start_authorize($request_ref);
+ return;
+ }
+
+ # refresh access token if it has already expired
+ if ((defined $access_expires_at) and ($access_expires_at < time)) {
+ ($refresh_token, $refresh_expires_at, $access_token, $access_expires_at) = refresh_access_token($refresh_token);
+ unless ($access_token) {
+ start_authorize($request_ref);
+ return;
+ }
+ }
+
+ # ID Token validation
+ #my $id_token = OIDC::Lite::Model::IDToken->load($token->id_token);
+
+ $log->info('request is ok', $request_ref) if $log->is_info();
+
+ return;
+}
+
+=head2 start_signout($request_ref)
+
+Initiates the sign-out process by redirecting the user to the authorization page.
+
+=head3 Arguments
+
+=head4 A reference to a hash containing request information. $request_ref
+
+=head3 Return Values
+
+None
+
+=cut
+
+sub start_signout ($request_ref) {
+ # compute return_url, so that after sign out, user will be redirected to the home page
+ my $return_url = single_param('return_url');
+ die $return_url if defined $return_url;
+ if ( (not $return_url)
+ or (not($return_url =~ /^https?:\/\/$subdomain\.$server_domain/sxm)))
+ {
+ $return_url = $formatted_subdomain;
+ }
+
+ my $id_token = $request_ref->{id_token};
+ unless ($User_id and $id_token) {
+ # user is not authenticated, nothing to do; sign-out is already done, redirect to home page
+ param('length', 'logout');
+ init_user($request_ref);
+ redirect_to_url($request_ref, 302, $return_url);
+ return;
+ }
+
+ _ensure_oidc_is_discovered();
+
+ # random private token to identify the sign-out process
+ my $nonce = generate_token(64);
+ my $end_session_endpoint = $oidc_discover_document->{end_session_endpoint};
+ my $redirect_url
+ = $end_session_endpoint
+ . '?post_logout_redirect_uri='
+ . uri_escape($signout_callback_uri)
+ . '&id_token_hint='
+ . uri_escape($id_token)
+ . '&state='
+ . uri_escape($nonce);
+
+ # start OIDC signout process by storing nonce and return_url in a cookie
+ $request_ref->{cookie} = generate_oidc_cookie($nonce, $return_url);
+ # then, redirect to OIDC end_session_endpoint
+ redirect_to_url($request_ref, 302, $redirect_url);
+ return;
+}
+
+=head2 signout_callback($request_ref)
+
+Handles the callback after successful sign-out, clears session cookie.
+
+=head3 Arguments
+
+=head4 A reference to a hash containing request information. $request_ref
+
+=head3 Return values
+
+The return URL after successful sign-out.
+
+=cut
+
+sub signout_callback ($request_ref) {
+ # no cookie, nothing to do
+ unless (defined cookie($cookie_name)) {
+ return $formatted_subdomain;
+ }
+
+ # ensure we are in the right process thanks to private random token
+ my $state = single_param('state');
+ my %cookie_ref = cookie($cookie_name);
+ my $nonce = $cookie_ref{'nonce'};
+ if (not($state eq $nonce)) {
+ $log->info('unexpected nonce', {nonce => $nonce, expected_nonce => $state}) if $log->is_info();
+ display_error_and_exit($request_ref, 'Invalid Nonce during OIDC logout', 500);
+ }
+
+ param('length', 'logout');
+ init_user($request_ref);
+
+ return $cookie_ref{'return_url'};
+}
+
+=head2 get_token_using_password_credentials($username, $password)
+
+Gets a token for the user.
+
+Method uses the Resource Owner Password Credentials Grant to
+with the given credentials, and pre-configured Client ID,
+and Client Secret.
+
+=head3 Arguments
+
+=head4 Name of the user $usersname
+
+=head4 Password given at sign-in $password
+
+=head3 Return values
+
+Open ID Access token, or undefined if sign-in wasn't successful.
+
+=cut
+
+sub get_token_using_password_credentials ($username, $password) {
+ _ensure_oidc_is_discovered();
+
+ # Build a request and emit it using our app specific key
+ # to authenticate user
+ my $token_request = HTTP::Request->new(POST => $oidc_discover_document->{token_endpoint});
+ $token_request->header('Content-Type' => 'application/x-www-form-urlencoded');
+ $token_request->content('grant_type=password&client_id='
+ . uri_escape($oidc_options{client_id})
+ . '&client_secret='
+ . uri_escape($oidc_options{client_secret})
+ . '&username='
+ . uri_escape($username)
+ . '&password='
+ . uri_escape($password)
+ . "&scope=openid%20profile%20offline_access");
+
+ my $token_response = LWP::UserAgent::Plugin->new->request($token_request);
+ unless ($token_response->is_success) {
+ $log->info('bad password - no token returned from IdP', {content => $token_response->content})
+ if $log->is_info();
+ return;
+ }
+
+ my $access_token = decode_json($token_response->content);
+ $log->info('got access token from password credentials', {access_token => $access_token}) if $log->is_info();
+ return $access_token;
+}
+
+=head2 get_token_using_client_credentials()
+
+Gets a token for the user.
+
+Method uses the Client Credentials Grant to
+pre-configured Client ID, and Client Secret.
+
+=head3 Arguments
+
+None
+
+=head3 Return values
+
+Open ID Access token, or undefined if sign-in wasn't successful.
+
+=cut
+
+sub get_token_using_client_credentials () {
+ _ensure_oidc_is_discovered();
+
+ my $token_request = HTTP::Request->new(POST => $oidc_discover_document->{token_endpoint});
+ $token_request->header('Content-Type' => 'application/x-www-form-urlencoded');
+ $token_request->content('grant_type=client_credentials&client_id='
+ . uri_escape($oidc_options{client_id})
+ . '&client_secret='
+ . uri_escape($oidc_options{client_secret}));
+ my $token_response = LWP::UserAgent::Plugin->new->request($token_request);
+ unless ($token_response->is_success) {
+ $log->info('bad client credentials - no token returned from IdP', {content => $token_response->content})
+ if $log->is_info();
+ return;
+ }
+
+ my $access_token = decode_json($token_response->content);
+ $log->info('got access token client credentials', {access_token => $access_token}) if $log->is_info();
+ return $access_token;
+}
+
+=head2 generate_oidc_cookie($nonce, $user_session)
+
+Generate a sign-in/sign-out cookie.
+
+The cookie is used to store information related to the current sign-in/sign-out
+for validation, and to redirect the user to the correct URL.
+
+=head3 Arguments
+
+=head4 Nonce $nonce
+
+=head4 Return URL after sign-in/-out $return_url
+
+=head3 Return values
+
+Sign-in/sign-out cookie.
+
+=cut
+
+sub generate_oidc_cookie ($nonce, $return_url) {
+ my $signin_ref = {'nonce' => $nonce, 'return_url' => $return_url};
+
+ my $cookie_ref = {
+ '-name' => $cookie_name,
+ '-value' => $signin_ref,
+ '-path' => '/',
+ '-domain' => $cookie_domain,
+ '-samesite' => 'Lax',
+ };
+
+ return cookie(%$cookie_ref);
+}
+
+=head2 verify_access_token($access_token_string)
+
+Verifies the access token by decoding and validating it using the JSON Web Key Set (JWKS).
+(see https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-key-sets)
+
+Parameters:
+- $access_token_string: The access token to be verified.
+
+Returns: The verified access token or undefined if verification fails.
+
+=cut
+
+sub verify_access_token ($access_token_string) {
+ _ensure_oidc_is_discovered();
+
+ my $access_token_verified = decode_jwt(token => $access_token_string, kid_keys => $jwks);
+ $log->debug('access_token found', {access_token => $access_token_string, access_token => $access_token_verified})
+ if $log->is_debug();
+ unless ($access_token_verified) {
+ return;
+ }
+
+ return $access_token_verified;
+}
+
+=head2 verify_id_token($id_token_string)
+
+Verifies the ID token by decoding and validating it using the JWKS.
+
+Parameters:
+- $id_token_string: The ID token to be verified.
+
+Returns: The verified ID token or undefined if verification fails.
+
+=cut
+
+sub verify_id_token ($id_token_string) {
+ _ensure_oidc_is_discovered();
+
+ my $id_token = OIDC::Lite::Model::IDToken->load($id_token_string);
+ my $id_token_verified = decode_jwt(token => $id_token_string, kid_keys => $jwks);
+ $log->debug('id_token found', {id_token => $id_token, id_token_verified => $id_token_verified}) if $log->is_debug();
+ unless ($id_token_verified) {
+ return;
+ }
+
+ return $id_token_verified;
+}
+
+=head2 get_azp($access_token)
+
+Retrieves the authorized party (client ID) from the access token.
+
+It is different for example between the website and the mobile app.
+
+This is useful for example for products change log.
+
+=head3 Arguments
+
+=head4 The access token. $access_token
+
+=head3 Return values
+
+The authorized party (client ID) or undefined if the token is not issued by the correct issuer.
+
+=cut
+
+sub get_azp ($access_token) {
+ if (not(defined $access_token)) {
+ return;
+ }
+
+ _ensure_oidc_is_discovered();
+
+ if ( (defined $oidc_discover_document->{issuer})
+ and (not($oidc_discover_document->{issuer} eq $access_token->{iss})))
+ {
+ $log->warn(
+ 'Given token was not issued by the correct issuer',
+ {
+ actual_iss => $access_token->{iss},
+ expected_iss => $oidc_discover_document->{issuer},
+ azp => $access_token->{azp},
+ sub => $access_token->{sub}
+ }
+ ) if $log->is_warn();
+ return;
+ }
+
+ return $access_token->{azp};
+}
+
+=head2 _get_client()
+
+Get the OIDC client that is used to interact with the OIDC server.
+
+This subroutine creates and returns an instance of the OIDC::Lite::Client::WebServer class, which represents the client profile for OpenID Connect (OIDC) authentication. The client profile is used to interact with the OIDC server for authentication and authorization purposes.
+
+The client profile is created with the following parameters:
+- id: The client ID provided by the OIDC server.
+- secret: The client secret provided by the OIDC server.
+- authorize_uri: The authorization endpoint URL provided by the OIDC server.
+- access_token_uri: The token endpoint URL provided by the OIDC server.
+
+If the client profile has already been created, it is returned directly without re-creating it.
+
+See L for more information on the OIDC::Lite::Client::WebServer module.
+
+=head3 Arguments
+
+None.
+
+=head3 Return values
+
+A workable instance of OIDC::Lite::Client::WebServer.
+
+=cut
+
+sub _get_client () {
+ if ($client) {
+ return $client;
+ }
+
+ _ensure_oidc_is_discovered();
+ $client = OIDC::Lite::Client::WebServer->new(
+ id => $oidc_options{client_id},
+ secret => $oidc_options{client_secret},
+ authorize_uri => $oidc_discover_document->{authorization_endpoint},
+ access_token_uri => $oidc_discover_document->{token_endpoint},
+ );
+ return $client;
+}
+
+=head2 _ensure_oidc_is_discovered( )
+
+Ensures that OIDC (OpenID Connect) is discovered and configured.
+
+If OIDC is already discovered, the function returns without doing anything.
+
+Otherwise, it sends a discovery request to the OIDC endpoint and loads the discovery document.
+If successful, it updates the OIDC options with the JWKS (JSON Web Key Set) configuration.
+
+=head3 Arguments
+
+None.
+
+=head3 Return values
+
+None.
+
+=cut
+
+sub _ensure_oidc_is_discovered () {
+ if ($jwks) {
+ return;
+ }
+
+ $log->info('Original OIDC configuration', {discovery_endpoint => $oidc_options{discovery_endpoint}})
+ if $log->is_info();
+
+ my $discovery_request = HTTP::Request->new(GET => $oidc_options{discovery_endpoint});
+ my $discovery_response = LWP::UserAgent::Plugin->new->request($discovery_request);
+ unless ($discovery_response->is_success) {
+ $log->info('Unable to load OIDC data from IdP', {response => $discovery_response->content}) if $log->is_info();
+ return;
+ }
+
+ $oidc_discover_document = decode_json($discovery_response->content);
+ $log->info('got discovery document', {discovery => $oidc_discover_document}) if $log->is_info();
+
+ _load_jwks_configuration_to_oidc_options($oidc_discover_document->{jwks_uri});
+
+ return;
+}
+
+=head2 _load_jwks_configuration_to_oidc_options( $jwks_uri )
+
+Loads the JWKS from $jwks_uri, and stores it in the $jkw variable.
+
+JWKS aka JSON Web Key Sets are essential to validate access tokens
+https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-key-sets
+
+=head3 Arguments
+
+=head4 URI to the JWKS. $jwks_uri
+
+=head3 Return values
+
+None.
+
+=cut
+
+sub _load_jwks_configuration_to_oidc_options ($jwks_uri) {
+ my $jwks_request = HTTP::Request->new(GET => $jwks_uri);
+ my $jwks_response = LWP::UserAgent::Plugin->new->request($jwks_request);
+ unless ($jwks_response->is_success) {
+ $log->info('Unable to load JWKS from IdP', {response => $jwks_response->content}) if $log->is_info();
+ return;
+ }
+
+ $jwks = decode_json($jwks_response->content);
+ $log->info('got JWKS', {jwks => $jwks}) if $log->is_info();
+ return;
+}
+
+=head2 write_auth_deprecated_headers()
+
+Writes the deprecation notice for old authentication sites as HTTP headers.
+
+=head3 Arguments
+
+None.
+
+=head3 Return values
+
+None.
+
+=cut
+
+sub write_auth_deprecated_headers() {
+ my $r = Apache2::RequestUtil->request();
+ $r->err_headers_out->set('Deprecation', 'Mon, 01 Apr 2024 00:00:00 GMT');
+ $r->err_headers_out->set('Sunset', 'Tue, 01 Apr 2025 18:00:00 GMT');
+ return;
+}
+
+1;
diff --git a/lib/ProductOpener/Config2_docker.pm b/lib/ProductOpener/Config2_docker.pm
index 251d2e5f30d3b..669a489316cf1 100755
--- a/lib/ProductOpener/Config2_docker.pm
+++ b/lib/ProductOpener/Config2_docker.pm
@@ -55,6 +55,7 @@ BEGIN {
$facets_kp_url
$events_username
$events_password
+ %oidc_options
$redis_url
%server_options
$build_cache_repo
@@ -126,6 +127,17 @@ $events_url = $ENV{EVENTS_URL};
$events_username = $ENV{EVENTS_USERNAME};
$events_password = $ENV{EVENTS_PASSWORD};
+%oidc_options = (
+ client_id => $ENV{PRODUCT_OPENER_OIDC_CLIENT_ID},
+ client_secret => $ENV{PRODUCT_OPENER_OIDC_CLIENT_SECRET},
+ discovery_endpoint => $ENV{PRODUCT_OPENER_OIDC_DISCOVERY_ENDPOINT},
+ # Keycloak specific endpoint used to create users. This is currently required for backwards compatibility with apps
+ # that create users by POSTing to /cgi/user.pl
+ keycloak_base_url => $ENV{KEYCLOAK_BASE_URL},
+ keycloak_backchannel_base_url => $ENV{KEYCLOAK_BACKCHANNEL_BASE_URL},
+ keycloak_realm_name => $ENV{KEYCLOAK_REALM_NAME}
+);
+
# Set this to your instance of https://github.com/openfoodfacts/facets-knowledge-panels
# Inject facet knowledge panels
$facets_kp_url = $ENV{FACETS_KP_URL};
diff --git a/lib/ProductOpener/Config2_sample.pm b/lib/ProductOpener/Config2_sample.pm
index dfa2978ef82dd..0f714ea21f9d5 100644
--- a/lib/ProductOpener/Config2_sample.pm
+++ b/lib/ProductOpener/Config2_sample.pm
@@ -47,6 +47,7 @@ BEGIN {
$events_url
$events_username
$events_password
+ %oidc_options
$redis_url
%server_options
@@ -90,6 +91,22 @@ $events_url = '';
$events_username = '';
$events_password = '';
+# Set this to match your instance of Keycloak
+%oidc_options = (
+ # This is the client ID of the "open-products-facts" client in Keycloak
+ client_id => '',
+ # This is the client secret of the "open-products-facts" client in Keycloak
+ client_secret => '',
+ # Well-known endpoint used to discover metadata about the OIDC provider
+ discovery_endpoint => '',
+ # Keycloak specific: Base URL for the Keycloak server
+ keycloak_base_url => '',
+ # Keycloak specific: Base URL for the backchannel communcation: https://www.keycloak.org/server/hostname
+ keycloak_backchannel_base_url => '',
+ # Keycloak specific: Name of the realm
+ keycloak_realm_name => ''
+);
+
$redis_url = '';
%server_options = (
diff --git a/lib/ProductOpener/Config_off.pm b/lib/ProductOpener/Config_off.pm
index 2e0fc1dc5e946..632a75d7ca3e1 100644
--- a/lib/ProductOpener/Config_off.pm
+++ b/lib/ProductOpener/Config_off.pm
@@ -80,6 +80,7 @@ BEGIN {
%options
%server_options
+ %oidc_options
@product_fields
@product_other_fields
@@ -470,6 +471,7 @@ $rate_limiter_blocking_enabled = $ProductOpener::Config2::rate_limiter_blocking_
# server options
%server_options = %ProductOpener::Config2::server_options;
+%oidc_options = %ProductOpener::Config2::oidc_options;
$build_cache_repo = $ProductOpener::Config2::build_cache_repo;
diff --git a/lib/ProductOpener/Display.pm b/lib/ProductOpener/Display.pm
index a2e9b65754ddf..1ddc29eb73d61 100644
--- a/lib/ProductOpener/Display.pm
+++ b/lib/ProductOpener/Display.pm
@@ -1,7 +1,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -169,7 +169,7 @@ use ProductOpener::Recipes qw(add_product_recipe_to_set analyze_recipes compute_
use ProductOpener::PackagerCodes
qw($ec_code_regexp %geocode_addresses %packager_codes init_geocode_addresses init_packager_codes);
use ProductOpener::Export qw(export_csv);
-use ProductOpener::API qw(add_error customize_response_for_product process_api_request);
+use ProductOpener::API qw(add_error customize_response_for_product process_api_request process_auth_header);
use ProductOpener::Units qw/g_to_unit/;
use ProductOpener::Cache qw/$max_memcached_object_size $memd generate_cache_key/;
use ProductOpener::Permissions qw/has_permission/;
@@ -177,7 +177,7 @@ use ProductOpener::ProductsFeatures qw(feature_enabled);
use ProductOpener::RequestStats qw(:all);
use Encode;
-use URI::Escape::XS;
+use URI::Escape::XS qw/uri_escape/;
use CGI::Carp qw(fatalsToBrowser);
use CGI qw(:cgi :cgi-lib :form escapeHTML charset);
use HTML::Entities;
@@ -395,6 +395,11 @@ sub process_template ($template_filename, $template_data_ref, $result_content_re
(not defined $template_data_ref->{org_id}) and $template_data_ref->{org_id} = $Org_id;
$template_data_ref->{owner_pretty_path} = get_owner_pretty_path();
+ if (defined $template_data_ref->{user_id} and defined $template_data_ref->{canon_url}) {
+ $template_data_ref->{keycloak_account_link}
+ = ProductOpener::Keycloak->new()->get_account_link($template_data_ref->{canon_url});
+ }
+
$template_data_ref->{flavor} = $flavor;
$template_data_ref->{options} = \%options;
$template_data_ref->{product_type} = $options{product_type};
@@ -474,6 +479,10 @@ sub process_template ($template_filename, $template_data_ref, $result_content_re
return $json->encode($var);
};
+ $template_data_ref->{uri_escape} = sub ($var) {
+ return uri_escape($var);
+ };
+
return ($tt->process($template_filename, $template_data_ref, $result_content_ref));
}
@@ -861,6 +870,21 @@ sub init_request ($request_ref = {}) {
}
) if $log->is_debug();
+ my $signed_in_oidc = process_auth_header($request_ref, $r);
+ if ($signed_in_oidc < 0) {
+ # We were sent a bad bearer token
+ # Otherwise we return an error page in HTML (including for v0 / v1 / v2 API queries)
+ if (not((defined $request_ref->{api_version}) and ($request_ref->{api_version} >= 3))
+ and (not($r->uri() =~ /\/cgi\/auth\.pl/)))
+ {
+ $log->debug(
+ "init_request - init_user error - display error page",
+ {init_user_error => $request_ref->{init_user_error}}
+ ) if $log->is_debug();
+ display_error_and_exit($request_ref, $signed_in_oidc, 403);
+ }
+ }
+
my $error = ProductOpener::Users::init_user($request_ref);
if ($error) {
# We were sent bad user_id / password credentials
@@ -10745,6 +10769,7 @@ sub display_product_history ($request_ref, $code, $product_ref) {
userid => $userid,
uuid => $uuid,
app_version => $app_version,
+ clientid => $change_ref->{clientid},
diffs => compute_changes_diff_text($change_ref),
comment => $comment
};
diff --git a/lib/ProductOpener/Keycloak.pm b/lib/ProductOpener/Keycloak.pm
new file mode 100644
index 0000000000000..9a6f2b66cf993
--- /dev/null
+++ b/lib/ProductOpener/Keycloak.pm
@@ -0,0 +1,280 @@
+# This file is part of Product Opener.
+#
+# Product Opener
+# Copyright (C) 2011-2024 Association Open Food Facts
+# Contact: contact@openfoodfacts.org
+# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
+#
+# Product Opener is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+=head1 NAME
+
+ProductOpener::Keycloak - Perl module for Keycloak user management
+
+=head1 DESCRIPTION
+
+This Perl module provides a class that can be used to access Keycloak's user management API.
+
+=cut
+
+package ProductOpener::Keycloak;
+
+use ProductOpener::PerlStandards;
+
+use Log::Any qw($log);
+
+use ProductOpener::Auth qw/get_token_using_client_credentials/;
+use ProductOpener::Config qw/:all/;
+
+use JSON;
+use LWP::UserAgent;
+use LWP::UserAgent::Plugin 'Retry';
+use HTTP::Request;
+use URI::Escape::XS qw/uri_escape/;
+
+sub new($class) {
+ my $self = {};
+ bless $self, $class;
+
+ unless ((defined $oidc_options{keycloak_base_url})
+ and (defined $oidc_options{keycloak_backchannel_base_url})
+ and (defined $oidc_options{keycloak_realm_name}))
+ {
+ die 'keycloak_base_url or keycloak_backchannel_base_url or keycloak_realm_name not configured';
+ }
+
+ $self->{users_endpoint}
+ = $oidc_options{keycloak_backchannel_base_url}
+ . '/admin/realms/'
+ . uri_escape($oidc_options{keycloak_realm_name})
+ . '/users';
+
+ $self->{account_service}
+ = $oidc_options{keycloak_base_url} . '/realms/' . uri_escape($oidc_options{keycloak_realm_name}) . '/account';
+
+ return $self;
+}
+
+=head2 get_or_refresh_token()
+
+Retrieves or refreshes the access token for managing users with Keycloak.
+
+If the token is not defined, it retrieves a new token using client credentials.
+If the token is defined but has expired, it refreshes the token.
+The token is stored in the object and its expiration time is updated.
+
+=head3 Arguments
+
+None
+
+=head3 Return values
+
+Returns the access token.
+Throws an exception if the token cannot be obtained.
+
+=cut
+
+sub get_or_refresh_token ($self) {
+ if (not(defined $self->{token})) {
+ $self->{token} = get_token_using_client_credentials();
+ $self->{token}->{expires_at} = time() + $self->{token}->{expires_in};
+ }
+ else {
+ my $now = time();
+ my $cutoff = $self->{token}->{expires_at} - 15;
+ if ($now > $self->{token}->{expires_at}) {
+ $self->{token} = get_token_using_client_credentials();
+ $self->{token}->{expires_at} = time() + $self->{token}->{expires_in};
+ }
+ }
+
+ return $self->{token} // die 'Could not get token to manage users with users_endpoint';
+}
+
+=head2 create_user ($user_ref, $password)
+
+Create use on keycloak side.
+
+This is needed as we register new users via an old, undocumented API function.
+We create the user properties file locally before, and we create the user in keycloak in this sub.
+
+=head3 Arguments
+
+=head4 User info hashmap reference $user_ref
+
+=head4 String $password
+
+=head3 Return Value
+
+A hashmap reference with created user information.
+
+=cut
+
+sub create_user ($self, $user_ref, $password) {
+ # use a special application authorization to handle creation
+ my $token = $self->get_or_refresh_token();
+ unless ($token) {
+ display_error_and_exit('Could not get token to manage users with keycloak_users_endpoint', 500);
+ }
+
+ # user creation payload
+ my $api_request_ref = {
+ email => $user_ref->{email},
+ emailVerified => $JSON::PP::true, # TODO: Keep this for compat with current register endpoint?
+ enabled => $JSON::PP::true,
+ username => $user_ref->{userid},
+ credentials => [
+ {
+ type => 'password',
+ temporary => $JSON::PP::false,
+ value => $password
+ }
+ ],
+ attributes => [
+ name => $user_ref->{name},
+ locale => $user_ref->{preferred_language},
+ country => $user_ref->{country},
+ reqested_org => $user_ref->{requested_org},
+ newsletter => ($user_ref->{newsletter} ? 'subscribe' : undef)
+ ]
+ };
+ my $json = encode_json($api_request_ref);
+
+ # create request with right headers
+ my $create_user_request = HTTP::Request->new(POST => $self->{users_endpoint});
+ $create_user_request->header('Content-Type' => 'application/json');
+ $create_user_request->header('Authorization' => $token->{token_type} . ' ' . $token->{access_token});
+ $create_user_request->content($json);
+ # issue the request to keycloak
+ my $new_user_response = LWP::UserAgent::Plugin->new->request($create_user_request);
+ unless ($new_user_response->is_success) {
+ display_error_and_exit($new_user_response->content, 500);
+ }
+
+ # continue the process by fetching user data,
+ # which profile location is given in previous response
+ my $get_user_request = HTTP::Request->new(GET => $new_user_response->header('location'));
+ $get_user_request->header('Content-Type' => 'application/json');
+ $get_user_request->header('Authorization' => $token->{token_type} . ' ' . $token->{access_token});
+ my $get_user_response = LWP::UserAgent::Plugin->new->request($get_user_request);
+ unless ($get_user_response->is_success) {
+ display_error_and_exit($get_user_response->content, 500);
+ }
+
+ my $json_response = $get_user_response->decoded_content(charset => 'UTF-8');
+ my @created_users = decode_json($json_response);
+ return $created_users[0];
+}
+
+=head2 find_user_by_username ($username)
+
+Try to find a user in Keycloak by their username.
+
+=head3 Arguments
+
+=head4 User's username $username
+
+=head3 Return Value
+
+A hashmap reference with user information from Keycloak.
+
+=cut
+
+sub find_user_by_username ($self, $username) {
+ return $self->_find_user_by_single_attribute_exact('username', $username);
+}
+
+=head2 find_user_by_email ($mail)
+
+Try to find a user in Keycloak by their mail address.
+
+=head3 Arguments
+
+=head4 User's mail address $mail
+
+=head3 Return Value
+
+A hashmap reference with user information from Keycloak.
+
+=cut
+
+sub find_user_by_email ($self, $email) {
+ return $self->_find_user_by_single_attribute_exact('email', $email);
+}
+
+=head2 get_account_link()
+
+Gets the link to the account service on Keycloak.
+
+=head3 Arguments
+
+=head4 Canonical URL of the current site string $url
+
+=head3 Return values
+
+Returns the URL.
+
+=cut
+
+sub get_account_link ($self, $url) {
+ return
+ $self->{account_service}
+ . '?referrer='
+ . uri_escape($oidc_options{client_id})
+ . '&referrer_uri='
+ . uri_escape($url);
+}
+
+=head2 _find_user_by_single_attribute_exact ($name, $value)
+
+Try to find a user in Keycloak by a single attribute key/value combo.
+
+This should only be used with unique attributes like email or username.
+
+=head3 Arguments
+
+=head4 Name of the attribute $name
+
+=head4 Value of the attribute $value
+
+=head3 Return Value
+
+A hashmap reference with user information from Keycloak.
+
+=cut
+
+sub _find_user_by_single_attribute_exact ($self, $name, $value) {
+ # use a special application authorization to handle search
+ my $token = $self->get_or_refresh_token();
+ unless ($token) {
+ display_error_and_exit('Could not get token to search users with keycloak_users_endpoint', 500);
+ }
+
+ # create request with right headers
+ my $search_uri = $self->{users_endpoint} . '?exact=true&' . uri_escape($name) . '=' . uri_escape($value);
+ my $search_user_request = HTTP::Request->new(GET => $search_uri);
+ $search_user_request->header('Accept' => 'application/json');
+ $search_user_request->header('Authorization' => $token->{token_type} . ' ' . $token->{access_token});
+ # issue the request to keycloak
+ my $search_user_response = LWP::UserAgent::Plugin->new->request($search_user_request);
+ unless ($search_user_response->is_success) {
+ display_error_and_exit($search_user_response->content, 500);
+ }
+
+ my $json_response = $search_user_response->decoded_content(charset => 'UTF-8');
+ my $users = decode_json($json_response);
+ return $$users[0];
+}
+
+1;
diff --git a/lib/ProductOpener/Minion.pm b/lib/ProductOpener/Minion.pm
new file mode 100644
index 0000000000000..d13a06bb71df3
--- /dev/null
+++ b/lib/ProductOpener/Minion.pm
@@ -0,0 +1,91 @@
+# This file is part of Product Opener.
+#
+# Product Opener
+# Copyright (C) 2011-2024 Association Open Food Facts
+# Contact: contact@openfoodfacts.org
+# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
+#
+# Product Opener is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+=head1 NAME
+
+ProductOpener::Minion - functions to integrate with minion
+
+=head1 DESCRIPTION
+
+C is handling pushing info to Redis
+to communicate updates to all services, including search-a-licious,
+as well as receiving updates from other services like Keycloak.
+
+=cut
+
+package ProductOpener::Minion;
+
+use ProductOpener::PerlStandards;
+use Exporter qw< import >;
+
+use Log::Any qw($log);
+
+BEGIN {
+ use vars qw(@ISA @EXPORT_OK %EXPORT_TAGS);
+ @EXPORT_OK = qw(
+
+ &get_minion
+ &queue_job
+
+ ); # symbols to export on request
+ %EXPORT_TAGS = (all => [@EXPORT_OK]);
+}
+
+use vars @EXPORT_OK;
+
+use ProductOpener::Config qw/:all/;
+
+use Minion;
+
+# Minion backend
+my $minion;
+
+=head2 get_minion()
+
+Function to get the backend minion
+
+=head3 Arguments
+
+None
+
+=head3 Return values
+
+The backend minion $minion
+
+=cut
+
+sub get_minion() {
+ if (not defined $minion) {
+ if (not defined $server_options{minion_backend}) {
+ print STDERR "No Minion backend configured in lib/ProductOpener/Config2.pm\n";
+ }
+ else {
+ print STDERR "Initializing Minion backend configured in lib/ProductOpener/Config2.pm\n";
+ $minion = Minion->new(%{$server_options{minion_backend}});
+ }
+ }
+ return $minion;
+}
+
+sub queue_job { ## no critic (Subroutines::RequireArgUnpacking)
+ return get_minion()->enqueue(@_);
+}
+
+1;
diff --git a/lib/ProductOpener/Producers.pm b/lib/ProductOpener/Producers.pm
index 5e6ceb8aecf3b..d4102ef91a9fb 100644
--- a/lib/ProductOpener/Producers.pm
+++ b/lib/ProductOpener/Producers.pm
@@ -1,7 +1,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -82,6 +82,7 @@ use ProductOpener::Export qw/export_csv/;
use ProductOpener::Import
qw/$IMPORT_MAX_PACKAGING_COMPONENTS import_csv_file import_products_categories_from_public_database/;
use ProductOpener::ImportConvert qw/clean_fields/;
+use ProductOpener::Minion qw/get_minion/;
use ProductOpener::Users qw/$Org_id $Owner_id $User_id %User/;
use ProductOpener::Orgs qw/update_export_date/;
@@ -93,37 +94,6 @@ use JSON::MaybeXS;
use Time::Local;
use Data::Dumper;
use Text::CSV();
-use Minion;
-
-# Minion backend
-my $minion;
-
-=head2 get_minion()
-
-Function to get the backend minion
-
-=head3 Arguments
-
-None
-
-=head3 Return values
-
-The backend minion $minion
-
-=cut
-
-sub get_minion() {
- if (not defined $minion) {
- if (not defined $server_options{minion_backend}) {
- print STDERR "No Minion backend configured in lib/ProductOpener/Config2.pm\n";
- }
- else {
- print STDERR "Initializing Minion backend configured in lib/ProductOpener/Config2.pm\n";
- $minion = Minion->new(%{$server_options{minion_backend}});
- }
- }
- return $minion;
-}
=head1 FUNCTIONS
@@ -2071,8 +2041,4 @@ sub update_export_status_for_csv_file_task ($job, $args_ref) {
return;
}
-sub queue_job { ## no critic (Subroutines::RequireArgUnpacking)
- return get_minion()->enqueue(@_);
-}
-
1;
diff --git a/lib/ProductOpener/Products.pm b/lib/ProductOpener/Products.pm
index 8f1781309af76..6720e2cb0ebc3 100644
--- a/lib/ProductOpener/Products.pm
+++ b/lib/ProductOpener/Products.pm
@@ -789,7 +789,7 @@ sub get_owner_id ($userid, $orgid, $ownerid) {
return $ownerid;
}
-=head2 init_product ( $userid, $orgid, $code, $countryid )
+=head2 init_product ( $userid, $orgid, $code, $countryid, $client_id = undef )
Initializes and return a $product_ref structure for a new product.
If $countryid is defined and is not "en:world", then assign this country for the countries field.
@@ -801,7 +801,7 @@ Returns a $product_ref structure
=cut
-sub init_product ($userid, $orgid, $code, $countryid) {
+sub init_product ($userid, $orgid, $code, $countryid, $client_id = undef) {
$log->debug("init_product", {userid => $userid, orgid => $orgid, code => $code, countryid => $countryid})
if $log->is_debug();
@@ -832,9 +832,8 @@ sub init_product ($userid, $orgid, $code, $countryid) {
product_type => $options{product_type},
};
- if (defined $server) {
- $product_ref->{server} = $server;
- }
+ $product_ref->{server} = $server if defined $server;
+ $product_ref->{created_by_client} = $client_id if defined $client_id;
if ((defined $server_options{private_products}) and ($server_options{private_products})) {
my $ownerid = get_owner_id($userid, $orgid, $Owner_id);
@@ -1135,7 +1134,7 @@ sub compute_sort_keys ($product_ref) {
return;
}
-=head2 store_product ($user_id, $product_ref, $comment)
+=head2 store_product ($user_id, $product_ref, $comment, $client_id = undef)
Save changes of a product:
- in a new .sto file on the disk
@@ -1145,7 +1144,7 @@ Before saving, some field values are computed, and product history and completen
=cut
-sub store_product ($user_id, $product_ref, $comment) {
+sub store_product ($user_id, $product_ref, $comment, $client_id = undef) {
my $code = $product_ref->{code};
my $product_id = $product_ref->{_id};
@@ -1377,6 +1376,7 @@ sub store_product ($user_id, $product_ref, $comment) {
# last_modified_t is the date of the last change of the product raw data
# last_updated_t is the date of the last change of the product derived data (e.g. ingredient analysis, scores etc.)
$product_ref->{last_modified_by} = $user_id;
+ $product_ref->{last_modified_by_client} = $client_id if defined $client_id;
$product_ref->{last_modified_t} = time() + 0;
$product_ref->{last_updated_t} = $product_ref->{last_modified_t};
if (not exists $product_ref->{creator}) {
@@ -1396,6 +1396,7 @@ sub store_product ($user_id, $product_ref, $comment) {
my $change_ref = {
userid => $user_id,
+ clientid => $client_id,
ip => remote_addr(),
t => $product_ref->{last_modified_t},
comment => $comment,
diff --git a/lib/ProductOpener/Redis.pm b/lib/ProductOpener/Redis.pm
index a15137665c019..beef196a5c71c 100644
--- a/lib/ProductOpener/Redis.pm
+++ b/lib/ProductOpener/Redis.pm
@@ -1,18 +1,37 @@
+# This file is part of Product Opener.
+#
+# Product Opener
+# Copyright (C) 2011-2024 Association Open Food Facts
+# Contact: contact@openfoodfacts.org
+# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
+#
+# Product Opener is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
=head1 NAME
-ProductOpener::Redis - functions to push information to redis
+ProductOpener::Redis - functions to integrate with redis
=head1 DESCRIPTION
C is handling pushing info to Redis
-to communicate updates to all services, including search-a-licious.
+to communicate updates to all services, including search-a-licious,
+as well as receiving updates from other services like Keycloak.
=cut
package ProductOpener::Redis;
-use ProductOpener::Config qw/:all/;
use ProductOpener::PerlStandards;
use Exporter qw< import >;
use Encode;
@@ -23,7 +42,10 @@ BEGIN {
@EXPORT_OK = qw(
&get_rate_limit_user_requests
&increment_rate_limit_requests
+ &subscribe_to_redis_streams
&push_to_redis_stream
+
+ &process_xread_stream_reply
); # symbols to export on request
%EXPORT_TAGS = (all => [@EXPORT_OK]);
}
@@ -31,8 +53,13 @@ BEGIN {
use vars @EXPORT_OK;
use Log::Any qw/$log/;
-use ProductOpener::Config qw/$redis_url/;
-use Redis;
+use ProductOpener::Config qw/:all/;
+use ProductOpener::Minion qw/queue_job/;
+use ProductOpener::Users qw/retrieve_user store_user/;
+use ProductOpener::Text qw/remove_tags_and_quote/;
+use ProductOpener::Store qw/get_string_id_for_lang/;
+use AnyEvent;
+use AnyEvent::RipeRedis;
=head2 $redis_client
@@ -60,11 +87,26 @@ sub init_redis() {
$log->debug("init_redis", {redis_url => $redis_url})
if $log->is_debug();
eval {
- $redis_client = Redis->new(
- server => $redis_url,
+ my ($host, $port) = split /:/, $redis_url;
+ $redis_client = AnyEvent::RipeRedis->new(
+ host => $host,
+ port => $port,
# we don't want to sacrifice too much performance for redis problems
cnx_timeout => 1,
write_timeout => 1,
+ on_connect => sub {
+ $log->info("Connected to Redis") if $log->is_info();
+ },
+
+ on_disconnect => sub {
+ $log->info("Disconnected from Redis") if $log->is_info();
+ },
+
+ on_error => sub {
+ my $err = shift;
+
+ $log->warn("Error from Redis", {error => $err}) if $log->is_warn();
+ },
);
};
if ($@) {
@@ -74,6 +116,180 @@ sub init_redis() {
return;
}
+=head2 subscribe_to_redis_streams ()
+
+Subscribe to redis stream to be informed about user deletions.
+
+=cut
+
+sub subscribe_to_redis_streams () {
+ if (!$redis_url) {
+ # No Redis URL provided, we can't push to Redis
+ if (!$sent_warning_about_missing_redis_url) {
+ $log->warn("Redis URL not provided for streaming") if $log->is_warn();
+ $sent_warning_about_missing_redis_url = 1;
+ }
+ return;
+ }
+
+ if (!defined $redis_client) {
+ # we where deconnected, try again
+ $log->info("Trying to reconnect to Redis") if $log->is_info();
+ init_redis();
+ }
+
+ if (!defined $redis_client) {
+ $log->warn("Can't connect to Redis") if $log->is_warn();
+ return;
+ }
+
+ _read_user_streams('$');
+
+ return;
+}
+
+sub _read_user_streams($search_from) {
+ my @streams = ('user-registered', 'user-deleted', $search_from, $search_from);
+
+ $log->info("Reading from Redis", {streams => \@streams}) if $log->is_info();
+ $redis_client->xread(
+ 'BLOCK' => 0,
+ 'STREAMS' => 'user-deleted',
+ 'user-registered',
+ $search_from,
+ $search_from,
+ sub {
+ my ($reply_ref, $err) = @_;
+ if ($err) {
+ $log->warn("Error reading from Redis", {error => $err}) if $log->is_warn();
+ return;
+ }
+
+ if ($reply_ref) {
+ my $last_processed_message_id = process_xread_stream_reply($reply_ref);
+ if ($last_processed_message_id) {
+ $search_from = $last_processed_message_id;
+ }
+ }
+
+ _read_user_streams($search_from);
+ return;
+ }
+ );
+
+ return;
+}
+
+sub process_xread_stream_reply($reply_ref) {
+ my $last_processed_message_id;
+
+ my @streams = @{$reply_ref};
+ foreach my $stream_ref (@streams) {
+ my @stream = @{$stream_ref};
+ if ($stream[0] eq 'user-registered') {
+ $last_processed_message_id = _process_registered_users_stream($stream[1]);
+ }
+ elsif ($stream[0] eq 'user-deleted') {
+ $last_processed_message_id = _process_deleted_users_stream($stream[1]);
+ }
+
+ }
+
+ return $last_processed_message_id,;
+}
+
+sub _process_registered_users_stream($stream_values_ref) {
+ my $last_processed_message_id;
+
+ foreach my $outer_ref (@{$stream_values_ref}) {
+ my @outer = @{$outer_ref};
+ my $message_id = $outer[0];
+ my @values = @{$outer[1]};
+
+ my %message_hash;
+ for (my $i = 0; $i < scalar(@values); $i += 2) {
+ my $key = $values[$i];
+ my $value = $values[$i + 1];
+ $message_hash{$key} = $value;
+ }
+
+ my $user_id = $message_hash{'userName'};
+ my $newsletter = $message_hash{'newsletter'};
+ my $requested_org = $message_hash{'requestedOrg'};
+ my $email = $message_hash{'email'};
+
+ $log->info("User registered", {user_id => $user_id, newsletter => $newsletter})
+ if $log->is_info();
+
+ # Create the user if they don't exist and set the properties
+ my $user_ref = retrieve_user($user_id);
+ unless ($user_ref) {
+ # This doesn't set registered_t and other fields,
+ # these are updated in Auth.pm when the user is redirected back to PO
+ $user_ref = {
+ userid => $user_id,
+ name => $user_id
+ };
+ }
+ $user_ref->{email} = $email;
+ if (defined $requested_org) {
+ $user_ref->{requested_org} = remove_tags_and_quote(decode utf8 => $requested_org);
+
+ my $requested_org_id = get_string_id_for_lang("no_language", $user_ref->{requested_org});
+
+ if ($requested_org_id ne "") {
+ $user_ref->{requested_org_id} = $requested_org_id;
+ $user_ref->{pro} = 1;
+ }
+ }
+ store_user($user_ref);
+
+ my $args_ref = {userid => $user_id};
+
+ queue_job(welcome_user => [$args_ref] => {queue => $server_options{minion_local_queue}});
+
+ # Subscribe to newsletter
+ if ($newsletter eq 'subscribe') {
+ queue_job(subscribe_user_newsletter => [$args_ref] => {queue => $server_options{minion_local_queue}});
+ }
+
+ # Register interest in joining an organisation
+ if (defined $requested_org) {
+ queue_job(process_user_requested_org => [$args_ref] => {queue => $server_options{minion_local_queue}});
+ }
+
+ $last_processed_message_id = $message_id;
+ }
+
+ return $last_processed_message_id;
+}
+
+sub _process_deleted_users_stream($stream_values_ref) {
+ my $last_processed_message_id;
+
+ foreach my $outer_ref (@{$stream_values_ref}) {
+ my @outer = @{$outer_ref};
+ my $message_id = $outer[0];
+ my @values = @{$outer[1]};
+
+ my %message_hash;
+ for (my $i = 0; $i < scalar(@values); $i += 2) {
+ my $key = $values[$i];
+ my $value = $values[$i + 1];
+ $message_hash{$key} = $value;
+ }
+
+ $log->info("User deleted", {user_id => $message_hash{'userName'}}) if $log->is_info();
+
+ my $args_ref = {userid => $message_hash{'userName'}};
+ queue_job(delete_user => [$args_ref] => {queue => $server_options{minion_local_queue}});
+
+ $last_processed_message_id = $message_id;
+ }
+
+ return $last_processed_message_id;
+}
+
=head2 push_to_redis_stream ($user_id, $product_ref, $action, $comment, $diffs)
Add an event to Redis stream to inform that a product was updated.
@@ -123,6 +339,7 @@ sub push_to_redis_stream ($user_id, $product_ref, $action, $comment, $diffs, $ti
if (defined $redis_client) {
$log->debug("Pushing product update to Redis", {product_code => $product_ref->{code}}) if $log->is_debug();
eval {
+ my $cv = AE::cv;
$redis_client->xadd(
# name of the Redis stream
$options{redis_stream_name},
@@ -135,11 +352,32 @@ sub push_to_redis_stream ($user_id, $product_ref, $action, $comment, $diffs, $ti
'code', Encode::encode_utf8($product_ref->{code}),
'rev', Encode::encode_utf8($product_ref->{rev}),
# product_type should be used over flavor (kept for backward compatibility)
- 'product_type', $options{product_type},
- 'flavor', $options{current_server},
- 'user_id', Encode::encode_utf8($user_id), 'action', Encode::encode_utf8($action),
- 'comment', Encode::encode_utf8($comment), 'diffs', encode_json($diffs)
+ 'product_type',
+ $options{product_type},
+ 'flavor',
+ $options{current_server},
+ 'user_id',
+ Encode::encode_utf8($user_id),
+ 'action',
+ Encode::encode_utf8($action),
+ 'comment',
+ Encode::encode_utf8($comment),
+ 'diffs',
+ encode_json($diffs),
+ sub {
+ my ($reply, $err) = @_;
+ if (defined $err) {
+ $log->warn("Error adding data to stream", {error => $err}) if $log->is_warn();
+ }
+ else {
+ $log->info("Data added to stream with ID", {reply => $reply}) if $log->is_info();
+ }
+
+ $cv->send;
+ return;
+ }
);
+ $cv->recv;
};
$error = $@;
}
diff --git a/lib/ProductOpener/Test.pm b/lib/ProductOpener/Test.pm
index a7d2fea99018f..2126748cd38db 100644
--- a/lib/ProductOpener/Test.pm
+++ b/lib/ProductOpener/Test.pm
@@ -1,7 +1,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -66,6 +66,7 @@ use ProductOpener::Config qw/:all/;
use ProductOpener::Paths qw/%BASE_DIRS/;
use ProductOpener::Data qw/execute_query get_products_collection/;
use ProductOpener::Store "store";
+use ProductOpener::Auth qw/get_token_using_client_credentials/;
use Carp qw/confess/;
use Data::DeepAccess qw(deep_exists deep_get deep_set);
@@ -79,6 +80,7 @@ use File::Path qw/make_path remove_tree/;
use File::Copy;
use Path::Tiny qw/path/;
use Scalar::Util qw(looks_like_number);
+use URI::Escape::XS qw/uri_escape/;
use Test::File::Contents qw/files_eq_or_diff/;
use Log::Any qw($log);
@@ -243,11 +245,18 @@ sub remove_all_users () {
# Important: check we are not on a prod database
check_not_production();
# clean files
- # clean files
remove_tree($BASE_DIRS{USERS}, {keep_root => 1, error => \my $err});
if (@$err) {
confess("not able to remove some users directories: " . join(":", @$err));
}
+ # clean keycloak
+ my @users = get_users_from_keycloak();
+ foreach (@users) {
+ foreach (@{$_}) {
+ _delete_user_from_keycloak($_);
+ }
+ }
+ return;
}
=head2 remove_all_orgs ()
@@ -890,4 +899,78 @@ sub wait_for ($code, $timeout = 3, $poll_time = 1) {
return $code->();
}
+=head2 get_users_from_keycloak()
+
+Get a list of users registered in our Keycloak realm
+
+=head3 Return values
+
+Returns an array of users in Keycloak.
+
+=cut
+
+sub get_users_from_keycloak () {
+ unless ((defined $oidc_options{keycloak_backchannel_base_url}) and (defined $oidc_options{keycloak_realm_name})) {
+ confess('keycloak_backchannel_base_url and keycloak_realm_name not configured');
+ }
+
+ my $token = get_token_using_client_credentials();
+ unless ($token) {
+ confess('Could not get token to manage users with keycloak_users_endpoint');
+ }
+
+ my $keycloak_users_endpoint
+ = $oidc_options{keycloak_backchannel_base_url}
+ . '/admin/realms/'
+ . uri_escape($oidc_options{keycloak_realm_name})
+ . '/users';
+ my $get_users_request = HTTP::Request->new(GET => $keycloak_users_endpoint);
+ $get_users_request->header('Accept' => 'application/json');
+ $get_users_request->header('Authorization' => $token->{token_type} . ' ' . $token->{access_token});
+ my $get_users_response = LWP::UserAgent->new->request($get_users_request);
+ unless ($get_users_response->is_success) {
+ confess($get_users_response->content);
+ }
+
+ my @users = decode_json($get_users_response->content);
+ return @users;
+}
+
+=head2 _delete_user_from_keycloak($keycloak_user)
+
+Removes the given users from our Keycloak realm
+
+=head3 parameters
+
+=head4 $user - sub
+
+The user that will be deleted from Keycloak
+
+=cut
+
+sub _delete_user_from_keycloak ($user) {
+ unless ((defined $oidc_options{keycloak_backchannel_base_url}) and (defined $oidc_options{keycloak_realm_name})) {
+ confess('keycloak_backchannel_base_url and keycloak_realm_name not configured');
+ }
+
+ my $token = get_token_using_client_credentials();
+ unless ($token) {
+ confess('Could not get token to manage users with keycloak_users_endpoint');
+ }
+
+ my $keycloak_users_endpoint
+ = $oidc_options{keycloak_backchannel_base_url}
+ . '/admin/realms/'
+ . uri_escape($oidc_options{keycloak_realm_name})
+ . '/users';
+ my $delete_user_request = HTTP::Request->new(DELETE => $keycloak_users_endpoint . '/' . $user->{id});
+ $delete_user_request->header('Authorization' => $token->{token_type} . ' ' . $token->{access_token});
+ my $delete_user_response = LWP::UserAgent->new->request($delete_user_request);
+ unless ($delete_user_response->is_success) {
+ confess($delete_user_response->content);
+ }
+
+ return;
+}
+
1;
diff --git a/lib/ProductOpener/TestDefaults.pm b/lib/ProductOpener/TestDefaults.pm
index 389812444df7b..4313dcea0266d 100644
--- a/lib/ProductOpener/TestDefaults.pm
+++ b/lib/ProductOpener/TestDefaults.pm
@@ -1,7 +1,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -50,7 +50,7 @@ The default test password
=cut
-my $test_password = "testtest";
+my $test_password = "!!!TestTest1!!!";
=head2 %default_user_form
diff --git a/lib/ProductOpener/URL.pm b/lib/ProductOpener/URL.pm
index fe253558629cc..92b4bf985e328 100644
--- a/lib/ProductOpener/URL.pm
+++ b/lib/ProductOpener/URL.pm
@@ -1,7 +1,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -50,6 +50,7 @@ BEGIN {
use vars qw(@ISA @EXPORT_OK %EXPORT_TAGS);
@EXPORT_OK = qw(
&format_subdomain
+ &get_cookie_domain
); # symbols to export on request
%EXPORT_TAGS = (all => [@EXPORT_OK]);
@@ -115,4 +116,29 @@ sub subdomain_supports_https ($sd) {
}
+=head2 get_cookie_domain( )
+
+C gets the domain that should be used for cookies.
+
+=head3 Arguments
+
+None.
+
+=head3 Return Values
+
+A URL that the server should use when emitting cookies.
+
+=cut
+
+sub get_cookie_domain() {
+ my $cookie_domain = '.' . $server_domain; # e.g. fr.openfoodfacts.org sets the domain to .openfoodfacts.org
+ $cookie_domain =~ s/\.pro\./\./; # e.g. .pro.openfoodfacts.org -> .openfoodfacts.org
+ if (defined $server_options{cookie_domain}) {
+ $cookie_domain
+ = '.' . $server_options{cookie_domain}; # e.g. fr.import.openfoodfacts.org sets domain to .openfoodfacts.org
+ }
+
+ return $cookie_domain;
+}
+
1;
diff --git a/lib/ProductOpener/Users.pm b/lib/ProductOpener/Users.pm
index 4cf64aeeec22c..7d5a0150c94a7 100644
--- a/lib/ProductOpener/Users.pm
+++ b/lib/ProductOpener/Users.pm
@@ -1,7 +1,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -62,22 +62,24 @@ BEGIN {
&init_user
&is_admin_user
- &create_password_hash
- &check_password_hash
&retrieve_user
&retrieve_userids
- &retrieve_user_by_email
&store_user
&store_user_session
&remove_user_by_org_admin
&add_users_to_org_by_admin
&is_suspicious_name
+ &is_email_has_off_account
&check_session
+ &open_user_session
&generate_token
&update_login_time
+ &welcome_user_task
+ &delete_user_task
+
); # symbols to export on request
%EXPORT_TAGS = (all => [@EXPORT_OK]);
}
@@ -96,15 +98,15 @@ use ProductOpener::Products qw/find_and_replace_user_id_in_products/;
use ProductOpener::Text qw/remove_tags_and_quote/;
use ProductOpener::Brevo qw/add_contact_to_list/;
use ProductOpener::CRM qw/update_contact_last_login/;
+use ProductOpener::Auth qw/:all/;
+use ProductOpener::Keycloak qw/:all/;
+use ProductOpener::URL qw/:all/;
use CGI qw/:cgi :form escapeHTML/;
use Encode;
use JSON::MaybeXS;
-use Email::Valid;
-use Crypt::PasswdMD5 qw(unix_md5_crypt);
use Math::Random::Secure qw(irand);
-use Crypt::ScryptKDF qw(scrypt_hash scrypt_hash_verify);
use Log::Any qw($log);
use MIME::Base32 qw(encode_base32);
@@ -113,12 +115,7 @@ my @user_groups = qw(producer database app bot moderator pro_moderator);
# Initialize some constants
my $cookie_name = 'session';
-my $cookie_domain = "." . $server_domain; # e.g. fr.openfoodfacts.org sets the domain to .openfoodfacts.org
-$cookie_domain =~ s/\.pro\./\./; # e.g. .pro.openfoodfacts.org -> .openfoodfacts.org
-if (defined $server_options{cookie_domain}) {
- $cookie_domain
- = "." . $server_options{cookie_domain}; # e.g. fr.import.openfoodfacts.org sets domain to .openfoodfacts.org
-}
+my $cookie_domain = get_cookie_domain();
=head1 FUNCTIONS
@@ -138,81 +135,122 @@ sub generate_token ($name_length) {
return join '', map {$chars[irand @chars]} 1 .. $name_length;
}
-=head2 create_password_hash($password)
+# we use user_init() now and not create_user()
-Takes $password and hashes it using scrypt
+=head2 welcome_user_task ($job, $args_ref)
-C This function hashes the user's password using Scrypt which is a salted hashing algorithm.
-Password salting adds a random sequence of data to each password and then hashes it. Password hashing is turning a password into a random string by using some algorithm.
+C Background task that welcomes a user.
+This function sends a welcome mail to the user.
=head3 Arguments
-$password : String
+Minion job arguments. $args_ref contains the userid and email
-=head3 Return values
+=cut
-Returns the salted hashed sequence.
+sub welcome_user_task ($job, $args_ref) {
+ return if not defined $job;
-=cut
+ my $job_id = $job->{id};
-sub create_password_hash ($password) {
+ my $log_message = "welcome_user_task - job: $job_id started - args: " . encode_json($args_ref) . "\n";
+ open(my $minion_log, ">>", "$BASE_DIRS{LOGS}/minion.log");
+ print $minion_log $log_message;
+ close($minion_log);
- return scrypt_hash($password);
-}
+ print STDERR $log_message;
-=head2 check_password_hash ($password, $hash)
+ my $userid = $args_ref->{userid};
+ my $user_ref = find_user_by_username($userid);
+
+ # Fetch the HTML mail template corresponding to the user language, english is the
+ # default if the translation is not available
+ my $language = $user_ref->{attributes}->{preferred_language};
+ my $email_content = get_html_email_content("user_welcome.html", $language);
+ my $user_name = $user_ref->{attributes}->{name} || $userid;
+ # Replace placeholders by user values
+ $email_content =~ s/\{\{USERID\}\}/$userid/g;
+ $email_content =~ s/\{\{NAME\}\}/$user_name/g;
+ send_html_email($user_ref, lang("add_user_email_subject"), $email_content);
-Turns $password into hash using md5 or scrypt and compares it to $hash.
+ $job->finish("done");
-C This function takes the hash generated by create_password_hash() and the input password string.
-Further, it hashes the input password string md5 or scrypt and verifies if it matches with stored password hash.
-If the stored hash matches the input-password hash, it returns 1. Otherwise, it's a 0.
+ return;
+}
-=head3 Arguments
+=head2 subscribe_user_newsletter_task ($job, $args_ref)
-Takes in 2 string: $password and $hash generated by create_password_hash()
+C Background task that adds the user to Brevo.
+This function uses the Brevo API to add the user to the address.
-=head3 Return values
+=head3 Arguments
-Boolean: This function returns a 1/0 (True or False)
+Minion job arguments. $args_ref contains the userid and email
=cut
-sub check_password_hash ($password, $hash) {
+sub subscribe_user_newsletter_task ($job, $args_ref) {
+ return if not defined $job;
- if ($hash =~ /^\$1\$(?:.*)/) {
- if ($hash eq unix_md5_crypt($password, $hash)) {
- return 1;
- }
- else {
- return 0;
- }
- }
- else {
- return scrypt_hash_verify($password, $hash);
+ my $job_id = $job->{id};
+
+ my $log_message = "subscribe_user_newsletter_task - job: $job_id started - args: " . encode_json($args_ref) . "\n";
+ open(my $minion_log, ">>", "$BASE_DIRS{LOGS}/minion.log");
+ print $minion_log $log_message;
+ close($minion_log);
+
+ print STDERR $log_message;
+
+ my $userid = $args_ref->{userid};
+ my $user_ref = find_user_by_username($userid);
+ if (not(defined $user_ref)) {
+ $job->fail({errors => ['User with id ' . $userid . ' not found in Keycloak.']});
+ return;
}
-}
-# we use user_init() now and not create_user()
+ add_contact_to_list(
+ $user_ref->{email}, $user_ref->{username},
+ $user_ref->{attributes}->{country},
+ $user_ref->{attributes}->{preferred_language}
+ );
+
+ $job->finish("done");
+
+ return;
+}
-=head2 delete_user ($user_ref)
+=head2 process_user_requested_org_task ($job, $args_ref)
-C Creates a background job to delete the user
+C Background task that to register a new user with an organisation
=head3 Arguments
-Takes in the $user_ref of the user to be deleted
+Minion job arguments. $args_ref contains the userid
=cut
-sub delete_user ($user_ref) {
- my $args_ref = {
- userid => get_string_id_for_lang("no_language", $user_ref->{userid}),
- email => $user_ref->{email},
- };
+sub process_user_requested_org_task ($job, $args_ref) {
+ return if not defined $job;
- require ProductOpener::Producers;
- ProductOpener::Producers::queue_job(delete_user => [$args_ref] => {queue => $server_options{minion_local_queue}});
+ my $job_id = $job->{id};
+
+ my $log_message = "process_user_requested_org_task - job: $job_id started - args: " . encode_json($args_ref) . "\n";
+ open(my $minion_log, ">>", "$BASE_DIRS{LOGS}/minion.log");
+ print $minion_log $log_message;
+ close($minion_log);
+
+ print STDERR $log_message;
+
+ my $userid = $args_ref->{userid};
+ my $user_ref = retreive_user($userid);
+ if (not(defined $user_ref)) {
+ $job->fail({errors => ['User with id ' . $userid . ' not found.']});
+ return;
+ }
+
+ process_user_requested_org($user_ref, {});
+
+ $job->finish("done");
return;
}
@@ -391,10 +429,11 @@ sub check_user_form ($request_ref, $type, $user_ref, $errors_ref) {
if ((defined $email) and ($email ne '') and ($user_ref->{email} ne $email)) {
# check that the email is not already used
- my $existing_user = retrieve_user_by_email($email);
- if (defined $existing_user and $existing_user->{userid} ne $user_ref->{userid}) {
+ my $user_id_from_mail = is_email_has_off_account($email);
+ if ((defined $user_id_from_mail) and ($user_id_from_mail ne $user_ref->{userid})) {
+ # email is already associated with an OFF account
$log->debug("check_user_form - email already in use",
- {type => $type, email => $email, existing_userid => $existing_user->{userid}})
+ {type => $type, email => $email, existing_userid => $user_id_from_mail})
if $log->is_debug();
push @{$errors_ref}, $Lang{error_email_already_in_use}{$lc};
}
@@ -404,10 +443,6 @@ sub check_user_form ($request_ref, $type, $user_ref, $errors_ref) {
$user_ref->{email} = $email;
}
- # Country and preferred language
- $user_ref->{preferred_language} = remove_tags_and_quote(single_param("preferred_language"));
- $user_ref->{country} = remove_tags_and_quote(single_param("country"));
-
# Is there a checkbox to make a professional account
if (defined single_param("pro_checkbox")) {
@@ -475,25 +510,14 @@ sub check_user_form ($request_ref, $type, $user_ref, $errors_ref) {
# Check input parameters, redisplay if necessary
- if (length($user_ref->{name}) < 2) {
- push @{$errors_ref}, $Lang{error_no_name}{$lc};
- }
- elsif (length($user_ref->{name}) > 60) {
- push @{$errors_ref}, $Lang{error_name_too_long}{$lc};
- }
-
- my $address;
- eval {$address = Email::Valid->address(-address => $user_ref->{email}, -mxcheck => 1);};
- $address = 0 if $@;
- if (not $address) {
- push @{$errors_ref}, $Lang{error_invalid_email}{$lc};
- }
- else {
- # If all checks have passed, reinitialize with modified email
- $user_ref->{email} = $address;
- }
-
if ($type eq 'add') {
+ if (length($user_ref->{name}) < 2) {
+ push @{$errors_ref}, $Lang{error_no_name}{$lc};
+ }
+ elsif (length($user_ref->{name}) > 60) {
+ push @{$errors_ref}, $Lang{error_name_too_long}{$lc};
+ }
+
if (length($user_ref->{userid}) < 2) {
push @{$errors_ref}, $Lang{error_no_username}{$lc};
}
@@ -506,17 +530,6 @@ sub check_user_form ($request_ref, $type, $user_ref, $errors_ref) {
elsif (length($user_ref->{userid}) > 40) {
push @{$errors_ref}, $Lang{error_username_too_long}{$lc};
}
-
- if (length(decode utf8 => single_param('password')) < 6) {
- push @{$errors_ref}, $Lang{error_invalid_password}{$lc};
- }
- }
-
- if (param('password') ne single_param('confirm_password')) {
- push @{$errors_ref}, $Lang{error_different_passwords}{$lc};
- }
- elsif (single_param('password') ne '') {
- $user_ref->{encrypted_password} = create_password_hash(encode_utf8(decode utf8 => single_param('password')));
}
return;
@@ -673,59 +686,30 @@ edit / add / delete
sub process_user_form ($type, $user_ref, $request_ref) {
my $userid = $user_ref->{userid};
- my $error = 0;
$log->debug("process_user_form", {type => $type, user_ref => $user_ref}) if $log->is_debug();
- # Professional account with a requested org (existing or new)
- process_user_requested_org($user_ref, $request_ref);
+ if ($type eq 'add') {
+ # Create new user in Keycloak first
+ my $keycloak = ProductOpener::Keycloak->new();
+ $keycloak->create_user($user_ref, single_param('password'));
+ }
+ else {
+ # Professional account with a requested org (existing or new)
+ # Keycloak round trip will do this for new users but still call here for edits
+ process_user_requested_org($user_ref, $request_ref);
+ }
# save user
store_user($user_ref);
- if ($type eq 'add') {
-
- # Initialize the session to send a session cookie back
- # so that newly created users do not have to login right after
-
- param("user_id", $userid);
- init_user($request_ref);
-
- # Fetch the HTML mail template corresponding to the user language, english is the
- # default if the translation is not available
- my $language = $user_ref->{preferred_language} || $user_ref->{initial_lc};
- my $email_content = get_html_email_content("user_welcome.html", $language);
- my $user_name = $user_ref->{name};
- # Replace placeholders by user values
- $email_content =~ s/\{\{USERID\}\}/$userid/g;
- $email_content =~ s/\{\{NAME\}\}/$user_name/g;
- $error = send_html_email($user_ref, lang("add_user_email_subject"), $email_content);
-
- my $admin_mail_body = <{name}
-email: $user_ref->{email}
-twitter: https://twitter.com/$user_ref->{twitter}
-newsletter: $user_ref->{newsletter}
-discussion: $user_ref->{discussion}
-lc: $user_ref->{initial_lc}
-cc: $user_ref->{initial_cc}
-
-EMAIL
- ;
- $error += send_email_to_admin("Inscription de $userid", $admin_mail_body);
- }
- # Check if the user subscribed to the newsletter
- if ($user_ref->{newsletter}) {
- add_contact_to_list($user_ref->{email}, $user_ref->{user_id}, $user_ref->{country},
- $user_ref->{preferred_language});
- }
+ param("user_id", $userid);
+ init_user($request_ref);
- return $error;
+ return;
}
=head2 check_edit_owner($user_ref, $errors_ref)
@@ -809,27 +793,6 @@ sub check_edit_owner ($user_ref, $errors_ref, $ownerid = undef) {
return;
}
-=head2 migrate_password_hash($user_ref)
-
-We used to use crypt instead of scrypt to store hashed passwords.
-If the user is logging in with a correct password, we can update the password hash.
-
-=head3 Arguments
-
-=head4 User object $user_ref
-
-=cut
-
-sub migrate_password_hash ($user_ref) {
-
- # Migration: take the occasion of having password to upgrade to scrypt, if it is still in crypt format
- if ($user_ref->{'encrypted_password'} =~ /^\$1\$(?:.*)/) {
- $user_ref->{'encrypted_password'} = create_password_hash(encode_utf8(decode utf8 => single_param('password')));
- $log->info("crypt password upgraded to scrypt_hash") if $log->is_info();
- }
- return;
-}
-
=head2 remove_old_sessions($user_ref)
Remove the oldest session if we have too many sessions opened for an user.
@@ -913,7 +876,7 @@ sub generate_session_cookie ($user_id, $user_session) {
return cookie(%$cookie_ref);
}
-=head2 open_user_session($user_ref, $request_ref)
+=head2 open_user_session($user_ref, $refresh_token, $refresh_expires_at, $access_token, $access_expires_at, $id_token, $request_ref)
Open a session, store it in the user object, and return a cookie with the session id in the request object.
@@ -921,6 +884,16 @@ Open a session, store it in the user object, and return a cookie with the sessio
=head4 User object $user_ref
+=head4 OIDC Refresh Token $refresh_token
+
+=head4 Timestamp after which the OIDC Refresh Token cannot be used $refresh_expires_at
+
+=head4 OIDC Access Token $access_token
+
+=head4 Timestamp after which the OIDC Access Token cannot be used $access_expires_at
+
+=head4 OIDC ID Token $id_token
+
=head4 Request object $request_ref
=head3 Return values
@@ -929,7 +902,9 @@ The cookie is returned in $request_ref
=cut
-sub open_user_session ($user_ref, $request_ref) {
+sub open_user_session ($user_ref, $refresh_token, $refresh_expires_at, $access_token, $access_expires_at, $id_token,
+ $request_ref)
+{
my $user_id = $user_ref->{'userid'};
@@ -944,7 +919,12 @@ sub open_user_session ($user_ref, $request_ref) {
# Store the ip and time corresponding to the given session
$user_ref->{'user_sessions'}{$user_session} = {
ip => remote_addr(),
- time => time()
+ time => time(),
+ refresh_token => $refresh_token,
+ refresh_expires_at => $refresh_expires_at,
+ access_token => $access_token,
+ access_expires_at => $access_expires_at,
+ id_token => $id_token
};
# Store user data
@@ -954,7 +934,7 @@ sub open_user_session ($user_ref, $request_ref) {
$request_ref->{cookie} = generate_session_cookie($user_id, $user_session);
- return;
+ return $user_session;
}
sub retrieve_user ($user_id) {
@@ -969,22 +949,86 @@ sub retrieve_user ($user_id) {
return $user_ref;
}
-sub user_exists ($user_id) {
- my $user_file = "$BASE_DIRS{USERS}/" . get_string_id_for_lang("no_language", $user_id) . ".sto";
- return (-e $user_file);
+sub is_email_has_off_account ($email) {
+
+ my $user = _find_user_by_email_in_keycloak($email);
+ unless (defined $user) {
+ return; # Email is not known in Keycloak
+ }
+
+ my $user_id = $user->{preferred_username};
+ my $user_ref = retrieve_user($user_id);
+ unless ($user_ref) {
+ $log->info('User not found', {user_id => $user_id}) if $log->is_info();
+ return; # Email is not associated with an OFF account
+ }
+
+ return $user_ref->{userid};
}
-sub retrieve_user_by_email($email) {
- my $user_ref;
- my $emails_ref = retrieve("$BASE_DIRS{USERS}/users_emails.sto");
- if (not defined $emails_ref->{$email}) {
- # not found, try with lower case email
- $email = lc $email;
+sub _find_user_by_email_in_keycloak($email) {
+ my $keycloak = ProductOpener::Keycloak->new();
+ return $keycloak->find_user_by_email($email);
+}
+
+=head2 _get_or_create_account_by_mail ($email)
+
+Tries to get a user based on their mail address from Keycloak.
+
+If the account is available in Keycloak, but does not exist
+as a `user.sto` file, yet, it will be created.
+
+=head3 Arguments
+
+=head4 string $email
+
+Mail address of the user
+
+=head4 boolean $require_verified_email
+
+If true, the email must be verified before proceeding.
+
+=head3 Return values
+
+User's user ID
+
+=cut
+
+sub _get_or_create_account_by_mail ($email, $require_verified_email = 0) {
+
+ my $user = _find_user_by_email_in_keycloak($email);
+ unless (defined $user) {
+ return; # Email is not known in Keycloak
}
- if (defined $emails_ref->{$email}) {
- $user_ref = retrieve_user($emails_ref->{$email}[0]);
+
+ my $user_id = $user->{preferred_username};
+ my $user_ref = retrieve_user($user_id);
+ unless ($user_ref) {
+ if ($require_verified_email and (not($user->{emailVerified} eq $JSON::PP::true))) {
+ $log->info('User not found, and user email is not verified. Not creating new account in OFF.',
+ {user => $user->{email}})
+ if $log->is_info();
+ return;
+ }
+
+ # Initialize user based on Keycloak data
+ $log->info('User not found. Creating based on Keycloak data', {user_id => $user_id, keycloak_user => $user})
+ if $log->is_info();
+ $user_ref = {
+ userid => $user->{username},
+ name => $user->{name} // $user->{username}
+ };
+
+ $user_ref->{email} = $user->{email};
+ store_user($user_ref);
}
- return $user_ref;
+
+ return $user_ref->{userid};
+}
+
+sub user_exists ($user_id) {
+ my $user_file = "$BASE_DIRS{USERS}/" . get_string_id_for_lang("no_language", $user_id) . ".sto";
+ return (-e $user_file);
}
# store user information that is not reflected in Keycloak
@@ -996,21 +1040,6 @@ sub store_user_session ($user_ref) {
}
sub store_user ($user_ref) {
- my $userid = $user_ref->{userid};
-
- # Update email
- my $emails_ref = retrieve("$BASE_DIRS{USERS}/users_emails.sto");
- my $email = $user_ref->{email};
-
- if ((defined $email) and ($email =~ /\@/)) {
- $emails_ref->{$email} = [$userid];
- }
- if (defined $user_ref->{old_email}) {
- delete $emails_ref->{$user_ref->{old_email}};
- delete $user_ref->{old_email};
- }
- store("$BASE_DIRS{USERS}/users_emails.sto", $emails_ref);
-
# save user
store_user_session($user_ref);
@@ -1026,13 +1055,16 @@ sub remove_user ($user_ref) {
unlink($user_file);
# Remove the e-mail
- my $emails_ref = retrieve("$BASE_DIRS{USERS}/users_emails.sto");
- my $email = $user_ref->{email};
-
- if ((defined $email) and ($email =~ /\@/)) {
- if (defined $emails_ref->{$email}) {
- delete $emails_ref->{$email};
- store("$BASE_DIRS{USERS}/users_emails.sto", $emails_ref);
+ my $emails_file = "$BASE_DIRS{USERS}/users_emails.sto";
+ if (-e $emails_file) {
+ my $emails_ref = retrieve($emails_file);
+ my $email = $user_ref->{email};
+
+ if ((defined $email) and ($email =~ /\@/)) {
+ if (defined $emails_ref->{$email}) {
+ delete $emails_ref->{$email};
+ store($emails_file, $emails_ref);
+ }
}
}
@@ -1057,13 +1089,6 @@ sub retrieve_userids() {
return @userids;
}
-sub is_email_has_off_account ($email) {
- my $user_ref = retrieve_user_by_email($email);
- return $user_ref->{userid} if defined $user_ref;
-
- return; # Email is not associated with an OFF account
-}
-
sub remove_user_by_org_admin ($orgid, $user_id) {
my $groups_ref = ['admins', 'members', 'pending'];
remove_user_from_org($orgid, $user_id, $groups_ref);
@@ -1132,6 +1157,36 @@ sub init_user ($request_ref) {
);
}
+ # User was authenticated via OIDC
+ elsif ((defined $request_ref->{oidc_user_id}) and ($request_ref->{oidc_user_id} ne '')) {
+ $user_id = $request_ref->{oidc_user_id};
+
+ $log->context->{user_id} = $user_id;
+ $log->debug("user_id is defined") if $log->is_debug();
+ my $session = undef;
+
+ # If the user exists
+ if (defined $user_id) {
+ $user_ref = retrieve_user($user_id);
+
+ if (defined $user_ref) {
+ $user_id = $user_ref->{'userid'};
+ $log->context->{user_id} = $user_id;
+
+ if (not defined request_param($request_ref, 'no_log')) # no need to store sessions for internal requests
+ {
+ open_user_session($user_ref, undef, undef, undef, undef, undef, $request_ref);
+ }
+ }
+ else {
+ $user_id = undef;
+ $log->info('bad user') if $log->is_info();
+ # Trigger an error
+ return ($Lang{error_bad_login_password}{$lc});
+ }
+ }
+ }
+
# Retrieve user_id and password from form parameters
elsif ( (defined request_param($request_ref, 'user_id'))
and (request_param($request_ref, 'user_id') ne '')
@@ -1141,20 +1196,11 @@ sub init_user ($request_ref) {
$user_id = remove_tags_and_quote(request_param($request_ref, 'user_id'));
if ($user_id =~ /\@/) {
- $log->info("got email while initializing user", {email => $user_id}) if $log->is_info();
- $user_ref = retrieve_user_by_email($user_id);
-
- if (not defined $user_ref) {
- $user_id = undef;
- $log->info("Unknown user e-mail", {email => $user_id}) if $log->is_info();
- # Trigger an error
+ $user_id = _get_or_create_account_by_mail($user_id);
+ # Trigger an error
+ unless (defined $user_id) {
return ($Lang{error_bad_login_password}{$lc});
}
- else {
- $user_id = $user_ref->{userid};
- }
-
- $log->info("corresponding user_id", {userid => $user_id}) if $log->is_info();
}
$log->context->{user_id} = $user_id;
@@ -1171,15 +1217,12 @@ sub init_user ($request_ref) {
$user_id = $user_ref->{'userid'};
$log->context->{user_id} = $user_id;
- my $hash_is_correct = check_password_hash(encode_utf8(request_param($request_ref, 'password')),
- $user_ref->{'encrypted_password'});
+ my ($oidc_user_id, $refresh_token, $refresh_expires_at, $access_token, $access_expires_at, $id_token)
+ = password_signin($user_id, encode_utf8(request_param($request_ref, 'password')), $request_ref);
# We don't have the right password
- if (not $hash_is_correct) {
+ if (not $oidc_user_id) {
$user_id = undef;
- $log->info(
- "bad password - input does not match stored hash",
- {encrypted_password => $user_ref->{'encrypted_password'}}
- ) if $log->is_info();
+ $log->info('bad password - input does not match stored hash') if $log->is_info();
# Trigger an error
return ($Lang{error_bad_login_password}{$lc});
}
@@ -1187,11 +1230,12 @@ sub init_user ($request_ref) {
elsif (
not defined request_param($request_ref, 'no_log')) # no need to store sessions for internal requests
{
- $log->info("correct password for user provided") if $log->is_info();
+ $user_id = $oidc_user_id;
- migrate_password_hash($user_ref);
+ $log->info("correct password for user provided") if $log->is_info();
- open_user_session($user_ref, $request_ref);
+ open_user_session($user_ref, $refresh_token, $refresh_expires_at,
+ $access_token, $access_expires_at, $id_token, $request_ref);
update_login_time($user_ref);
}
}
@@ -1269,6 +1313,15 @@ sub init_user ($request_ref) {
$log->debug("user identified", {user_id => $user_id, stocked_user_id => $user_ref->{'userid'}})
if $log->is_debug();
$user_id = $user_ref->{'userid'};
+
+ my $session_ref = $user_ref->{'user_sessions'}{$user_session};
+ $request_ref->{access_token} = $session_ref->{access_token} if $session_ref->{access_token};
+ $request_ref->{access_expires_at} = $session_ref->{access_expires_at}
+ if $session_ref->{access_expires_at};
+ $request_ref->{refresh_token} = $session_ref->{refresh_token} if $session_ref->{refresh_token};
+ $request_ref->{refresh_expires_at} = $session_ref->{refresh_expires_at}
+ if $session_ref->{refresh_expires_at};
+ $request_ref->{id_token} = $session_ref->{id_token} if $session_ref->{id_token};
}
}
else {
diff --git a/lib/startup_apache2.pl b/lib/startup_apache2.pl
index 7d0bc9e1320c6..e0907f14cd3a4 100755
--- a/lib/startup_apache2.pl
+++ b/lib/startup_apache2.pl
@@ -3,7 +3,7 @@
# This file is part of Product Opener.
#
# Product Opener
-# Copyright (C) 2011-2023 Association Open Food Facts
+# Copyright (C) 2011-2024 Association Open Food Facts
# Contact: contact@openfoodfacts.org
# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
#
@@ -118,6 +118,7 @@
use ProductOpener::NutritionCiqual qw/:all/;
use ProductOpener::NutritionEstimation qw/:all/;
use ProductOpener::RequestStats qw/:all/;
+use ProductOpener::Auth qw/:all/;
use Apache2::Const -compile => qw(OK);
use Apache2::Connection ();
diff --git a/po/common/common.pot b/po/common/common.pot
index b399fbb7f1549..e3fd117b6b594 100644
--- a/po/common/common.pot
+++ b/po/common/common.pot
@@ -617,20 +617,24 @@ msgid "Change your account parameters"
msgstr ""
msgctxt "edit_user"
-msgid "Account parameters"
-msgstr "Account parameters"
+msgid "Preferences"
+msgstr ""
msgctxt "edit_user_display"
-msgid "Account parameters"
-msgstr "Account parameters"
+msgid "Preferences"
+msgstr ""
msgctxt "edit_user_process"
-msgid "Account parameters"
-msgstr "Account parameters"
+msgid "Preferences"
+msgstr ""
msgctxt "edit_user_result"
-msgid "Your account parameters have been changed."
-msgstr "Your account parameters have been changed."
+msgid "Your preference shave been changed."
+msgstr ""
+
+msgctxt "user_in_keycloak"
+msgid "Account parameters"
+msgstr ""
msgctxt "editors_p"
msgid "editors"
@@ -4968,6 +4972,10 @@ msgctxt "number_of_members"
msgid "Number of Members"
msgstr "Number of Members"
+msgctxt "serial_no"
+msgid "S.No"
+msgstr "S.No"
+
msgctxt "contact_form"
msgid "Contact form"
msgstr "Contact form"
@@ -6950,6 +6958,11 @@ msgctxt "skip_to_content"
msgid "Skip to Content"
msgstr "Skip to Content"
+msgctxt "oidc_signin_no_cookie"
+msgid "You need to enable cookies to sign in."
+msgstr "You need to enable cookies to sign in."
+
+
msgctxt "cant_delete_main_contact"
msgid "You cannot remove the main contact. Change it first."
msgstr "You cannot remove the main contact. Change it first."
diff --git a/po/common/en.po b/po/common/en.po
index 282b66755d97c..3fd7148a008f8 100644
--- a/po/common/en.po
+++ b/po/common/en.po
@@ -622,20 +622,24 @@ msgid "Change your account parameters"
msgstr "Change your account parameters"
msgctxt "edit_user"
-msgid "Account parameters"
-msgstr "Account parameters"
+msgid "Preferences"
+msgstr "Preferences"
msgctxt "edit_user_display"
-msgid "Account parameters"
-msgstr "Account parameters"
+msgid "Preferences"
+msgstr "Preferences"
msgctxt "edit_user_process"
-msgid "Account parameters"
-msgstr "Account parameters"
+msgid "Preferences"
+msgstr "Preferences"
msgctxt "edit_user_result"
-msgid "Your account parameters have been changed."
-msgstr "Your account parameters have been changed."
+msgid "Your preference shave been changed."
+msgstr "Your preference shave been changed."
+
+msgctxt "user_in_keycloak"
+msgid "Account parameters"
+msgstr "Account parameters"
msgctxt "editors_p"
msgid "editors"
@@ -4992,6 +4996,10 @@ msgctxt "number_of_members"
msgid "Number of Members"
msgstr "Number of Members"
+msgctxt "serial_no"
+msgid "S.No"
+msgstr "S.No"
+
msgctxt "contact_form"
msgid "Contact form"
msgstr "Contact form"
@@ -7204,3 +7212,7 @@ msgstr "Bad repairability"
msgctxt "concerned_categories"
msgid "Bad repairability"
msgstr "Bad repairability"
+
+msgctxt "oidc_signin_no_cookie"
+msgid "You need to enable cookies to sign in."
+msgstr "You need to enable cookies to sign in."
diff --git a/scripts/create_dummies.pl b/scripts/create_dummies.pl
new file mode 100755
index 0000000000000..5beaa3e4d7686
--- /dev/null
+++ b/scripts/create_dummies.pl
@@ -0,0 +1,33 @@
+#!/usr/bin/perl -w
+
+use ProductOpener::PerlStandards;
+use ProductOpener::Users;
+
+my $type = 'add';
+my $rndm = ProductOpener::Users::generate_token(4);
+my $request_ref = {};
+my $uid = $rndm . 0;
+my $user_ref = {
+ email => $uid . '@example.org',
+ userid => $uid,
+ name => $uid,
+ password => 'testtest',
+ preferred_language => 'de',
+ country => 'de:Germany'
+};
+my @errors = ();
+ProductOpener::Users::check_user_form($request_ref, $type, $user_ref, \@errors);
+
+if ($#errors > 0) {
+ use Data::Dumper;
+ print STDERR Dumper(\@errors) . "\n";
+ return 1;
+}
+
+for (my $i = 1; $i <= 200000; $i++) {
+ my $uid = $rndm . $i;
+ $user_ref->{email} = $uid . '@example.org';
+ $user_ref->{userid} = $uid;
+ $user_ref->{name} = $uid;
+ ProductOpener::Users::process_user_form($type, $user_ref, $request_ref);
+}
diff --git a/scripts/deploy/verify-deployment.sh b/scripts/deploy/verify-deployment.sh
index 1b673849a0091..7110311a6ea14 100644
--- a/scripts/deploy/verify-deployment.sh
+++ b/scripts/deploy/verify-deployment.sh
@@ -203,7 +203,6 @@ function compute_expected_links {
EXPECTED_LINKS["/etc/logrotate.d/apache2"]="$REPO_PATH/conf/logrotate/apache2"
# Note: other link on old versions:
- # /srv/$SERVICE/users_emails.sto -> /srv/$SERVICE/users/users_emails.sto
# /srv/$SERVICE/orgs_glns.sto -> /srv/$SERVICE/orgs/orgs_glns.sto
#
}
diff --git a/scripts/listen_to_redis_stream.pl b/scripts/listen_to_redis_stream.pl
new file mode 100755
index 0000000000000..6ddcc4039a427
--- /dev/null
+++ b/scripts/listen_to_redis_stream.pl
@@ -0,0 +1,63 @@
+#!/usr/bin/perl -w
+
+# This file is part of Product Opener.
+#
+# Product Opener
+# Copyright (C) 2011-2024 Association Open Food Facts
+# Contact: contact@openfoodfacts.org
+# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
+#
+# Product Opener is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+# This script is meant to be called through process_new_image_off.sh, itself run through an icrontab
+
+use ProductOpener::PerlStandards;
+
+use ProductOpener::Config qw/:all/;
+use ProductOpener::Redis qw/:all/;
+
+use Log::Any qw($log);
+use Log::Any::Adapter ('Stderr', log_level => 'debug');
+
+use AnyEvent;
+use EV;
+
+sub run ($cv) {
+ subscribe_to_redis_streams();
+
+ # call event loop
+ $cv->recv; # Wait for the event loop to finish
+ EV::run();
+ return;
+}
+
+sub main() {
+ $log->info("Starting listen_to_redis_stream.pl") if $log->is_info();
+
+ my $cv = AE::cv;
+
+ # signal handler for TERM, KILL, QUIT
+ foreach my $sig (qw/TERM KILL QUIT/) {
+ EV::signal $sig, sub {
+ $log->info("Exiting after receiving", {signal => $sig}) if $log->is_info();
+ $cv->send;
+ exit(0);
+ };
+ }
+
+ run($cv);
+ return;
+}
+
+main();
diff --git a/scripts/migrate_users_to_keycloak.pl b/scripts/migrate_users_to_keycloak.pl
new file mode 100755
index 0000000000000..8d784cadc7db9
--- /dev/null
+++ b/scripts/migrate_users_to_keycloak.pl
@@ -0,0 +1,236 @@
+#!/usr/bin/perl -w
+
+# This file is part of Product Opener.
+#
+# Product Opener
+# Copyright (C) 2011-2024 Association Open Food Facts
+# Contact: contact@openfoodfacts.org
+# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
+#
+# Product Opener is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+use ProductOpener::PerlStandards;
+
+use ProductOpener::Auth qw/:all/;
+use ProductOpener::Config qw/:all/;
+use ProductOpener::Paths qw/:all/;
+use ProductOpener::Store qw/:all/;
+use ProductOpener::Keycloak qw/:all/;
+
+use JSON;
+use LWP::UserAgent;
+use LWP::UserAgent::Plugin 'Retry';
+use HTTP::Request;
+use URI::Escape::XS qw/uri_escape/;
+
+use Log::Any '$log', default_adapter => 'Stderr';
+
+my $keycloak = ProductOpener::Keycloak->new();
+
+my $keycloak_partialimport_endpoint
+ = $oidc_options{keycloak_backchannel_base_url}
+ . '/admin/realms/'
+ . uri_escape($oidc_options{keycloak_realm_name})
+ . '/partialImport';
+
+sub create_user_in_keycloak_with_scrypt_credential ($keycloak_user_ref) {
+ my $json = encode_json($keycloak_user_ref);
+
+ my $request_token = $keycloak->get_or_refresh_token();
+ my $create_user_request = HTTP::Request->new(POST => $keycloak->{users_endpoint});
+ $create_user_request->header('Content-Type' => 'application/json');
+ $create_user_request->header(
+ 'Authorization' => $request_token->{token_type} . ' ' . $request_token->{access_token});
+ $create_user_request->content($json);
+ my $new_user_response = LWP::UserAgent::Plugin->new->request($create_user_request);
+ unless ($new_user_response->is_success) {
+ $log->error($new_user_response->content);
+ return;
+ }
+
+ $request_token = $keycloak->get_or_refresh_token();
+ my $get_user_request = HTTP::Request->new(GET => $new_user_response->header('location'));
+ $get_user_request->header('Content-Type' => 'application/json');
+ $get_user_request->header('Authorization' => $request_token->{token_type} . ' ' . $request_token->{access_token});
+ my $get_user_response = LWP::UserAgent::Plugin->new->request($get_user_request);
+ unless ($get_user_response->is_success) {
+ $log->error($get_user_response->content);
+ return;
+ }
+
+ my $json_response = $get_user_response->decoded_content(charset => 'UTF-8');
+ my @created_users = decode_json($json_response);
+ return $created_users[0];
+}
+
+sub import_users_in_keycloak ($users_ref) {
+ my $request_data = {users => $users_ref};
+ my $json = encode_json($request_data);
+
+ my $request_token = $keycloak->get_or_refresh_token();
+ $log->error($keycloak_partialimport_endpoint);
+ my $import_users_request = HTTP::Request->new(POST => $keycloak_partialimport_endpoint);
+ $import_users_request->header('Content-Type' => 'application/json');
+ $import_users_request->header(
+ 'Authorization' => $request_token->{token_type} . ' ' . $request_token->{access_token});
+ $import_users_request->content($json);
+ my $import_users_response = LWP::UserAgent::Plugin->new->request($import_users_request);
+
+ unless ($import_users_response->is_success) {
+ $log->error(
+ 'There was an error importing users to Keycloak. Please ensure that the client has permission to manage the realm. This is not enabled by default and should only be a temporary permission.',
+ {
+ response => $import_users_response->content,
+ client_id => $oidc_options{client_id},
+ keycloak_realm_name => $oidc_options{keycloak_realm_name}
+ }
+ );
+ }
+
+ return;
+}
+
+sub migrate_user ($user_file, $anonymize) {
+ my $keycloak_user_ref = convert_to_keycloak_user($user_file, $anonymize);
+ if (not(defined $keycloak_user_ref)) {
+ $log->warn('unable to convert user_ref');
+ return;
+ }
+
+ create_user_in_keycloak_with_scrypt_credential($keycloak_user_ref);
+
+ return;
+}
+
+sub convert_to_keycloak_user ($user_file, $anonymize) {
+ my $user_ref = retrieve($user_file);
+ if (not(defined $user_ref)) {
+ $log->warn('undefined $user_ref');
+ return;
+ }
+
+ my $credential
+ = $anonymize ? {} : convert_scrypt_password_to_keycloak_credentials($user_ref->{'encrypted_password'}) // {};
+ my $keycloak_user_ref = {
+ email => ($anonymize ? 'off.' . $user_ref->{userid} . '@example.org' : $user_ref->{email}),
+ # Currently, the assumption is that all users have verified their email address. This is not true, but it's better than forcing all existing users to verify their email address.
+ emailVerified => $JSON::PP::true,
+ enabled => $JSON::PP::true,
+ username => $user_ref->{userid},
+ credentials => [$credential],
+ attributes => [
+ name => [($anonymize ? $user_ref->{userid} : $user_ref->{name})],
+ locale => [$user_ref->{initial_lc}],
+ country => [$user_ref->{initial_cc}],
+ importTimestamp => time(),
+ importSourceChangedTimestamp => (stat($user_file))[9]
+ ],
+ createdTimestamp => $user_ref->{registered_t} * 1000
+ };
+
+ return $keycloak_user_ref;
+}
+
+sub convert_scrypt_password_to_keycloak_credentials ($hashed_password) {
+ unless (defined $hashed_password) {
+ return;
+ }
+
+ my $credential = {};
+ my ($alg, $N, $r, $p, $salt, $hash) = ($hashed_password =~ /^(SCRYPT):(\d+):(\d+):(\d+):([^\:]+):([^\:]+)$/);
+ if ((defined $alg) and ($alg eq 'SCRYPT')) {
+ # Only migrate SCRYPT passwords. If there are still users that use MD5,
+ # they haven't signed in in 8 years, and will have to change their
+ # password, if they want to use the server again.
+ $credential->{type} = 'password';
+
+ my $secret_data = {
+ value => $hash,
+ salt => $salt
+ };
+
+ $credential->{secretData} = encode_json($secret_data);
+
+ my $credential_data = {
+ hashIterations => -1,
+ algorithm => 'scrypt',
+ additionalParameters => {
+ N => [$N],
+ r => [$r],
+ p => [$p],
+ }
+ };
+
+ $credential->{credentialData} = encode_json($credential_data);
+ $credential->{temporary} = $JSON::false;
+ }
+
+ return $credential;
+}
+
+my $importtype = 'realm-batch';
+if ((scalar @ARGV) > 0 and (length($ARGV[0]) > 0)) {
+ $importtype = $ARGV[0];
+}
+
+my $anonymize = 0;
+if ((scalar @ARGV) > 0 and ('anonymize' eq $ARGV[-1])) {
+ # Anonymize the user data by removing the email address, name, and password.
+ # This is useful for testing the migration script and for adding production data to the test server.
+ $anonymize = 1;
+}
+
+if ($importtype eq 'realm-batch') {
+ my @users = ();
+
+ if (opendir(my $dh, "$BASE_DIRS{USERS}/")) {
+
+ foreach my $file (readdir($dh)) {
+ if (($file =~ /.+\.sto$/) and ($file ne 'users_emails.sto')) {
+ my $keycloak_user = convert_to_keycloak_user("$BASE_DIRS{USERS}/$file", $anonymize);
+ push(@users, $keycloak_user) if defined $keycloak_user;
+ }
+
+ if (scalar @users >= 2000) {
+ import_users_in_keycloak(\@users);
+ @users = ();
+ }
+ }
+
+ closedir $dh;
+ }
+
+ if (scalar @users) {
+ import_users_in_keycloak(\@users);
+ }
+}
+elsif ($importtype eq 'api-multi') {
+ if (opendir(my $dh, "$BASE_DIRS{USERS}/")) {
+ foreach my $file (readdir($dh)) {
+ if (($file =~ /.+\.sto$/) and ($file ne 'users_emails.sto')) {
+ migrate_user("$BASE_DIRS{USERS}/$file", $anonymize);
+ }
+ }
+
+ closedir $dh;
+ }
+}
+elsif ($importtype eq 'api-single') {
+ if ((scalar @ARGV) == 2 and (length($ARGV[1]) > 0)) {
+ migrate_user($ARGV[1], $anonymize);
+ }
+}
+else {
+ die "Unknown import type: $importtype";
+}
diff --git a/scripts/minion_producers.pl b/scripts/minion_producers.pl
index 799816b9cbf34..04fa6e0ae6a37 100755
--- a/scripts/minion_producers.pl
+++ b/scripts/minion_producers.pl
@@ -73,6 +73,9 @@
app->minion->add_task(
import_products_categories_from_public_database => \&import_products_categories_from_public_database_task);
+app->minion->add_task(welcome_user => \&ProductOpener::Users::welcome_user_task);
+app->minion->add_task(subscribe_user_newsletter => \&ProductOpener::Users::subscribe_user_newsletter_task);
+app->minion->add_task(process_user_requested_org => \&ProductOpener::Users::process_user_requested_org_task);
app->minion->add_task(delete_user => \&ProductOpener::Users::delete_user_task);
app->config(
diff --git a/scripts/snippets/remove_broken_entry_with_empty_email.pl b/scripts/snippets/remove_broken_entry_with_empty_email.pl
deleted file mode 100755
index eb24484684f27..0000000000000
--- a/scripts/snippets/remove_broken_entry_with_empty_email.pl
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/usr/bin/perl -w
-
-use ProductOpener::PerlStandards;
-
-use ProductOpener::Config qw/:all/;
-use ProductOpener::Paths qw/:all/;
-use ProductOpener::Store qw/:all/;
-
-# TODO: This script probably won't be needed with Keycloak
-my $emails_ref = retrieve("$BASE_DIRS{USERS}/users_emails.sto");
-
-if (defined $emails_ref->{''}) {
- delete $emails_ref->{''};
- store("$BASE_DIRS{USERS}/users_emails.sto", $emails_ref);
-}
diff --git a/scripts/taxonomies/generators/gen_nutrient_levels_taxonomy.pl b/scripts/taxonomies/generators/gen_nutrient_levels_taxonomy.pl
old mode 100755
new mode 100644
diff --git a/stop_words.txt b/stop_words.txt
index c59566ab8f8a5..34942c200e44d 100644
--- a/stop_words.txt
+++ b/stop_words.txt
@@ -41,6 +41,8 @@ Carrefour
Catégorie
Catalogue
céléri
+cgi
+CGI
CIC
CIN
CALNUT
@@ -147,7 +149,10 @@ JS
json
JSON
jsonp
+JWKS
kcal
+Keycloak
+keycloak
kJ
Kokosnussöl
l'acérola
@@ -192,6 +197,9 @@ off
Offals
openfoodfacts
OpenFoodFacts
+OpenID
+oidc
+OIDC
Origine
overriden
packagings
@@ -216,6 +224,7 @@ poudre
pre
pre-processing
processings
+ProductOpener
Programme
pre
prepend
@@ -236,6 +245,8 @@ scrypt
Scrypt
serverTimePretty
sftp
+signin
+signout
SignalConso
sirop
slad
diff --git a/templates/web/common/site_layout.tt.html b/templates/web/common/site_layout.tt.html
index f36e0b2e6d452..b449653d7d9cf 100644
--- a/templates/web/common/site_layout.tt.html
+++ b/templates/web/common/site_layout.tt.html
@@ -109,7 +109,7 @@
+ Aidez-nous à faire de la transparence alimentaire la norme !
+
+
+
+
En tant qu'organisation à but non lucratif, nous dépendons de vos dons pour continuer à informer les consommateurs du monde entier sur ce qu'ils mangent.