diff --git a/.gitignore b/.gitignore
index 3981b1fce150ddd5381214510586fb3de4f6b63b..c014544fac7bec4aa260424a239cd448dadaeb9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,10 @@
 composer.lock
 config.php
+config.json
 logs/
 vendor/
-nbproject/
+client-icons/
+/nbproject/
+*.pem
+/build/
+.phpunit.result.cache
diff --git a/.htaccess b/.htaccess
index 0654970d0361d0925f2a271bf72101b1c16784c9..c59e2c4287f2112150acb12814705eae00ed4148 100644
--- a/.htaccess
+++ b/.htaccess
@@ -3,3 +3,12 @@ RewriteEngine On
 RewriteCond %{REQUEST_FILENAME} !-f
 RewriteCond %{REQUEST_FILENAME} !-d
 RewriteRule ^(.*)$ index.php [QSA,L]
+
+# mod_rewrite changes some Shibboleth headers
+# this restores them:
+SetEnvIf REDIRECT_Shib-Session-ID (.+) Shib-Session-ID=$1
+SetEnvIf REDIRECT_eppn (.+) eppn=$1
+SetEnvIf REDIRECT_mail (.+) mail=$1
+SetEnvIf REDIRECT_givenName (.+) givenName=$1
+SetEnvIf REDIRECT_sn (.+) sn=$1
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..98be7a84d85f6ae8f685ceec7f988bdf76be5450
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,57 @@
+FROM ubuntu:18.10
+
+# To fix "configuring tzdata" interactive input during apt install
+ENV DEBIAN_FRONTEND=noninteractive
+
+RUN apt-get update && \
+    apt-get install -yq --no-install-recommends \
+    apache2 \
+    libapache2-mod-php7.2 \
+    php7.2-xml \
+    php7.2-mbstring \
+    php-mysql \
+    php-curl \
+    libapache2-mod-shib2 \
+    make \
+    wget \
+    ca-certificates \
+    ssl-cert \
+    vim
+
+# Copying Shibboleth SP configuration
+COPY docker/shibboleth/shibboleth2.xml /etc/shibboleth/
+COPY docker/shibboleth/sp-key.pem /etc/shibboleth/
+COPY docker/shibboleth/sp-cert.pem /etc/shibboleth/
+    
+# Installing Embedded Discovery Service
+WORKDIR /usr/local/src
+
+RUN wget https://shibboleth.net/downloads/embedded-discovery-service/1.2.1/shibboleth-embedded-ds-1.2.1.tar.gz -O shibboleth-eds.tar.gz
+RUN tar xzf shibboleth-eds.tar.gz
+
+WORKDIR shibboleth-embedded-ds-1.2.1
+RUN make install
+
+RUN mv /etc/shibboleth-ds/shibboleth-ds.conf /etc/apache2/conf-available/shibboleth-ds.conf
+RUN sed -i 's/Allow from All/Require all granted/g' /etc/apache2/conf-available/shibboleth-ds.conf
+RUN a2enconf shibboleth-ds.conf
+
+# Adding RAP Apache configuration
+COPY docker/rap.conf /etc/apache2/conf-available/
+RUN a2enconf rap.conf
+
+# Enable mod_rewrite (for Flight framework)
+RUN a2enmod rewrite
+RUN a2enmod ssl
+RUN a2ensite default-ssl
+
+# Copying RAP php files
+WORKDIR /var/www/html
+COPY . rap-ia2
+
+WORKDIR /var/www/html/rap-ia2
+RUN mkdir -p logs
+RUN chown -R www-data logs
+
+# Starting shibd & Apache
+CMD service shibd start && apachectl -D FOREGROUND
diff --git a/README.md b/README.md
index 6912d4dd51b13a41c66293c1c65ee19e86b6f72b..462ca07ab2fd345a295176f54a21d473458c9174 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Requirements:
 
 On Ubuntu:
 
-    sudo apt install apache2 mariadb-server libapache2-mod-php mariadb-server
+    sudo apt install apache2 mariadb-server libapache2-mod-php mariadb-server php7.2-xml php7.2-mbstring php-mysql php-curl
 
 ### PHP
 
@@ -108,6 +108,10 @@ Before using social API it is necessary to register an application on each socia
 
 Copy the `config-example.php` into `config.php` and edit it for matching your needs.
 
+### Generate keypair
+
+    php exec/generate-keypair.php
+
 ### Logs directory
 
 Create the logs directory and assign ownership to the Apache user (usually www-data or apache)
@@ -115,6 +119,22 @@ Create the logs directory and assign ownership to the Apache user (usually www-d
     mkdir logs
     sudo chown www-data logs
 
+### Run Unit Tests and build code coverage report
+
+(XDebug or another code coverage driver needs to be installed; e.g. `sudo apt install php-xdebug`)
+
+    ./vendor/bin/phpunit --bootstrap vendor/autoload.php --coverage-html build/coverage-report tests/
+
 ## Additional information and developer guide
 
 See the wiki: https://www.ict.inaf.it/gitlab/zorba/rap-ia2/wikis/home
+
+## Troubleshooting
+
+### Class not found while developing
+
+If you see a message like this:
+
+    PHP Fatal error:  Uncaught Error: Class 'RAP\\[...]' not found
+
+probably you have to regenerate the PHP autoload calling `composer dumpautoload` in RAP root directory.
diff --git a/auth/oauth2/facebook_login.php b/auth/oauth2/facebook_login.php
deleted file mode 100755
index 844cf9f6a6dc8c677f1f43c1229a89e949d3bda3..0000000000000000000000000000000000000000
--- a/auth/oauth2/facebook_login.php
+++ /dev/null
@@ -1,46 +0,0 @@
-<?php
-
-/* ----------------------------------------------------------------------------
- *               INAF - National Institute for Astrophysics
- *               IRA  - Radioastronomical Institute - Bologna
- *               OATS - Astronomical Observatory - Trieste
- * ----------------------------------------------------------------------------
- *
- * Copyright (C) 2016 Istituto Nazionale di Astrofisica
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License Version 3 as published by the
- * Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
- * details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program; if not, write to the Free Software Foundation, Inc., 51
- * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-/* This page uses the Facebook API for generating the redirect URL to use for Facebook login */
-
-include '../../include/init.php';
-startSession();
-
-// Retrieve Facebook configuration
-$Facebook = $AUTHENTICATION_METHODS['Facebook'];
-
-$fb = new Facebook\Facebook([
-    'app_id' => $Facebook['id'],
-    'app_secret' => $Facebook['secret'],
-    'default_graph_version' => $Facebook['version'],
-        ]);
-
-$helper = $fb->getRedirectLoginHelper();
-
-$permissions = ['email']; // Optional permissions: we need user email
-
-$loginUrl = $helper->getLoginUrl($Facebook['callback'], $permissions);
-
-header("Location: $loginUrl");
-?>
diff --git a/auth/oauth2/facebook_token.php b/auth/oauth2/facebook_token.php
deleted file mode 100755
index ef7446143aea2c92d8cf87bcd2bcbe322a60bc0e..0000000000000000000000000000000000000000
--- a/auth/oauth2/facebook_token.php
+++ /dev/null
@@ -1,111 +0,0 @@
-<?php
-
-/* ----------------------------------------------------------------------------
- *               INAF - National Institute for Astrophysics
- *               IRA  - Radioastronomical Institute - Bologna
- *               OATS - Astronomical Observatory - Trieste
- * ----------------------------------------------------------------------------
- *
- * Copyright (C) 2016 Istituto Nazionale di Astrofisica
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License Version 3 as published by the
- * Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
- * details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program; if not, write to the Free Software Foundation, Inc., 51
- * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-/* Facebook callback page */
-
-include '../../include/init.php';
-startSession();
-
-// Retrieve Facebook configuration
-$Facebook = $AUTHENTICATION_METHODS['Facebook'];
-
-$fb = new Facebook\Facebook([
-    'app_id' => $Facebook['id'],
-    'app_secret' => $Facebook['secret'],
-    'default_graph_version' => $Facebook['version'],
-        ]);
-
-$helper = $fb->getRedirectLoginHelper();
-if (isset($_GET['state'])) {
-    $helper->getPersistentDataHandler()->set('state', $_GET['state']);
-}
-
-try {
-    // obtaining current URL without query string
-    $url = "https://$_SERVER[HTTP_HOST]" . strtok($_SERVER["REQUEST_URI"], '?');
-    $accessToken = $helper->getAccessToken($url);
-} catch (Facebook\Exceptions\FacebookResponseException $e) {
-    // When Graph returns an error
-    http_response_code(500);
-    die('Graph returned an error: ' . $e->getMessage());
-} catch (Facebook\Exceptions\FacebookSDKException $e) {
-    // When validation fails or other local issues
-    http_response_code(500);
-    die('Facebook SDK returned an error: ' . $e->getMessage());
-}
-if (!isset($accessToken)) {
-    if ($helper->getError()) {
-        $errorMessage = "Error: " . $helper->getError() . "<br>";
-        $errorMessage = $errorMessage . "Error Code: " . $helper->getErrorCode() . "<br>";
-        $errorMessage = $errorMessage . "Error Reason: " . $helper->getErrorReason() . "<br>";
-        $errorMessage = $errorMessage . "Error Description: " . $helper->getErrorDescription();
-    } else {
-        $errorMessage = "Bad request";
-    }
-
-    http_response_code(500);
-    die($errorMessage);
-}
-
-try {
-    // Returns a `Facebook\FacebookResponse` object
-    $response = $fb->get('/me?fields=id,first_name,last_name,email', $accessToken);
-} catch (Facebook\Exceptions\FacebookResponseException $e) {
-    echo 'Graph returned an error: ' . $e->getMessage();
-    exit;
-} catch (Facebook\Exceptions\FacebookSDKException $e) {
-    echo 'Facebook SDK returned an error: ' . $e->getMessage();
-    exit;
-}
-
-$_SESSION['fb_access_token'] = (string) $accessToken;
-
-$fbUser = $response->getGraphUser();
-
-$typedId = $fbUser["id"];
-
-// Search if the user is already registered into RAP using the Facebook ID.
-$user = $userHandler->findUserByIdentity(RAP\Identity::FACEBOOK, $typedId);
-
-if ($user === null) {
-    // Create new user
-    $user = new RAP\User();
-
-    $identity = new RAP\Identity(RAP\Identity::FACEBOOK);
-    $identity->email = $fbUser["email"];
-    $identity->name = $fbUser["first_name"];
-    $identity->surname = $fbUser["last_name"];
-    $identity->typedId = $typedId;
-
-    $user->addIdentity($identity);
-
-    $session->userToLogin = $user;
-    $session->save();
-    header('Location: ' . $BASE_PATH . '/tou-check');
-    die();
-}
-
-$auditLog->info("LOGIN,Facebook," . $user->id);
-$callbackHandler->manageLoginRedirect($user, $session);
-?>
diff --git a/auth/oauth2/google_token.php b/auth/oauth2/google_token.php
deleted file mode 100644
index 16831cc69bb0a67df476cc4a2e6a4f29b86d5db8..0000000000000000000000000000000000000000
--- a/auth/oauth2/google_token.php
+++ /dev/null
@@ -1,111 +0,0 @@
-<?php
-
-/* ----------------------------------------------------------------------------
- *               INAF - National Institute for Astrophysics
- *               IRA  - Radioastronomical Institute - Bologna
- *               OATS - Astronomical Observatory - Trieste
- * ----------------------------------------------------------------------------
- *
- * Copyright (C) 2016 Istituto Nazionale di Astrofisica
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License Version 3 as published by the
- * Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
- * details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program; if not, write to the Free Software Foundation, Inc., 51
- * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-/* Google redirect and callback page */
-
-include '../../include/init.php';
-startSession();
-
-// Retrieve Google configuration
-$Google = $AUTHENTICATION_METHODS['Google'];
-
-$client = new Google_Client(array(
-    'client_id' => $Google['id'],
-    'client_secret' => $Google['secret'],
-    'redirect_uri' => $Google['callback'],
-        ));
-
-// Ask permission to obtain user email and profile information
-$client->setScopes(array(Google_Service_People::USERINFO_EMAIL, Google_Service_People::USERINFO_PROFILE));
-
-if (isset($_REQUEST['logout'])) {
-// Reset the access token stored into the session
-    unset($_SESSION['access_token']);
-}
-
-if (isset($_GET['code'])) {
-// An access token has been returned from the auth URL.
-    $client->authenticate($_GET['code']);
-    $_SESSION['access_token'] = $client->getAccessToken();
-}
-
-//if (isset($_SESSION['access_token'])) {
-//    $client->setAccessToken($_SESSION['access_token']);
-//}
-
-if ($client->getAccessToken()) {
-
-    // Query web service for retrieving user information
-    $service = new Google_Service_People($client);
-
-    try {
-        $res = $service->people->get('people/me', array('requestMask.includeField' => 'person.names,person.email_addresses'));
-    } catch (Google_Service_Exception $e) {
-        echo '<p>' . json_encode($e->getErrors()) . '</p>';
-        $thisPage = $PROTOCOL . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'];
-        echo '<p><a href="' . $thisPage . '?logout">Click here to unset the access token</a></p>';
-    }
-
-    $name = $res->getNames()[0]->getGivenName();
-    $surname = $res->getNames()[0]->getFamilyName();
-
-    $emailAddresses = [];
-    foreach ($res->getEmailAddresses() as $addr) {
-        array_push($emailAddresses, $addr->value);
-    }
-
-    $typedId = explode('/', $res->getResourceName())[1];
-
-    // Search if the user is already registered into RAP using the Google ID.
-    $user = $userHandler->findUserByIdentity(RAP\Identity::GOOGLE, $typedId);
-
-    if ($user === null) {
-        // Create new user
-        $user = new RAP\User();
-
-        $identity = new RAP\Identity(RAP\Identity::GOOGLE);
-        $identity->email = $emailAddresses[0];
-        $identity->name = $name;
-        $identity->surname = $surname;
-        $identity->typedId = $typedId;
-
-        $user->addIdentity($identity);
-
-        $session->userToLogin = $user;
-        $session->save();
-        header('Location: ' . $BASE_PATH . '/tou-check');
-        die();
-    }
-
-    $auditLog->info("LOGIN,Google," . $user->id);
-    $callbackHandler->manageLoginRedirect($user, $session);
-
-    die();
-} else {
-    // Redirect to Google authorization URL for obtaining an access token
-    $authUrl = $client->createAuthUrl();
-    header('Location: ' . $authUrl);
-    die();
-}
-?>
diff --git a/auth/oauth2/linkedin_login.php b/auth/oauth2/linkedin_login.php
deleted file mode 100644
index ac03db4e5ed33b4aad2be905ecd50260d99b8a47..0000000000000000000000000000000000000000
--- a/auth/oauth2/linkedin_login.php
+++ /dev/null
@@ -1,40 +0,0 @@
-<?php
-
-/* ----------------------------------------------------------------------------
- *               INAF - National Institute for Astrophysics
- *               IRA  - Radioastronomical Institute - Bologna
- *               OATS - Astronomical Observatory - Trieste
- * ----------------------------------------------------------------------------
- *
- * Copyright (C) 2016 Istituto Nazionale di Astrofisica
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License Version 3 as published by the
- * Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
- * details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program; if not, write to the Free Software Foundation, Inc., 51
- * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-/* This page redirects to LinkedIn login page */
-
-include '../../include/init.php';
-startSession();
-
-// Retrieve LinkedIn configuration
-$LinkedIn = $AUTHENTICATION_METHODS['LinkedIn'];
-
-$url = "https://www.linkedin.com/oauth/v2/authorization?response_type=code";
-$url .= "&client_id=" . $LinkedIn['id'];
-$url .= "&redirect_uri=" . $LinkedIn['callback'];
-$url .= "&state=789654123";
-$url .= "&scope=r_liteprofile%20r_emailaddress%20w_member_social";
-
-header("Location: $url");
-?>
diff --git a/auth/oauth2/linkedin_token.php b/auth/oauth2/linkedin_token.php
deleted file mode 100644
index c35a7d248bfa6ca661ae803a7865ae6f3dd025e8..0000000000000000000000000000000000000000
--- a/auth/oauth2/linkedin_token.php
+++ /dev/null
@@ -1,164 +0,0 @@
-<?php
-
-/* ----------------------------------------------------------------------------
- *               INAF - National Institute for Astrophysics
- *               IRA  - Radioastronomical Institute - Bologna
- *               OATS - Astronomical Observatory - Trieste
- * ----------------------------------------------------------------------------
- *
- * Copyright (C) 2016 Istituto Nazionale di Astrofisica
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License Version 3 as published by the
- * Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
- * details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program; if not, write to the Free Software Foundation, Inc., 51
- * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-/* LinkedIn callback page. Curl is used, because LinkedIn doesn't provide official PHP API. */
-
-include '../../include/init.php';
-startSession();
-
-// Retrieve LinkedIn configuration
-$LinkedIn = $AUTHENTICATION_METHODS['LinkedIn'];
-
-if (!isset($_REQUEST['code'])) {
-    die("Unable to get LinkedIn client code");
-}
-
-//create array of data to be posted to get AccessToken
-$post_data = array(
-    'grant_type' => "authorization_code",
-    'code' => $_REQUEST['code'],
-    'redirect_uri' => $LinkedIn['callback'],
-    'client_id' => $LinkedIn['id'],
-    'client_secret' => $LinkedIn['secret']);
-
-//traverse array and prepare data for posting (key1=value1)
-foreach ($post_data as $key => $value) {
-    $post_items[] = $key . '=' . $value;
-}
-
-//create the final string to be posted
-$post_string = implode('&', $post_items);
-
-//create cURL connection
-$conn1 = curl_init('https://www.linkedin.com/oauth/v2/accessToken');
-
-//set options
-curl_setopt($conn1, CURLOPT_CONNECTTIMEOUT, 30);
-curl_setopt($conn1, CURLOPT_RETURNTRANSFER, true);
-curl_setopt($conn1, CURLOPT_SSL_VERIFYPEER, true);
-curl_setopt($conn1, CURLOPT_FOLLOWLOCATION, 1);
-
-//set data to be posted
-curl_setopt($conn1, CURLOPT_POSTFIELDS, $post_string);
-
-//perform our request
-$result1 = curl_exec($conn1);
-$info1 = curl_getinfo($conn1);
-
-if ($info1['http_code'] === 200) {
-    $my_token = json_decode($result1, TRUE);
-    $access_token = $my_token['access_token'];
-    $expires_in = $my_token['expires_in'];
-    curl_close($conn1);
-} else {
-    //show information regarding the error
-    $errorMessage = "Error: LinkedIn server response code: " . $info1['http_code'] . " - ";
-    $errorMessage .= curl_error($conn1);
-    curl_close($conn1);
-    http_response_code(500);
-    die($errorMessage);
-}
-
-// Call to API
-$conn2 = curl_init();
-curl_setopt($conn2, CURLOPT_URL, "https://api.linkedin.com/v2/me");
-curl_setopt($conn2, CURLOPT_HTTPHEADER, array(
-    'Authorization: Bearer ' . $access_token
-));
-
-curl_setopt($conn2, CURLOPT_RETURNTRANSFER, true);
-$result = curl_exec($conn2);
-$info2 = curl_getinfo($conn2);
-
-if ($info2['http_code'] === 200) {
-    $data = json_decode($result, TRUE);
-
-    curl_close($conn2);
-
-    if (isset($data['errorCode'])) {
-        $errorMessage = $data['message'];
-        die($errorMessage);
-    }
-
-    $typedId = $data['id'];
-
-    // Search if the user is already registered into RAP using the LinkedIn ID.
-    $user = $userHandler->findUserByIdentity(RAP\Identity::LINKEDIN, $typedId);
-
-    if ($user === null) {
-
-      // Recall to API for email
-      $conn2 = curl_init();
-      curl_setopt($conn2, CURLOPT_URL, "https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))");
-      curl_setopt($conn2, CURLOPT_HTTPHEADER, array(
-           'Authorization: Bearer ' . $access_token
-      ));
-
-      curl_setopt($conn2, CURLOPT_RETURNTRANSFER, true);
-      $result = curl_exec($conn2);
-      $info2 = curl_getinfo($conn2);
-
-      if ($info2['http_code'] === 200) {
-        $data2 = json_decode($result, TRUE);
-
-        curl_close($conn2);
-
-        if (isset($data['errorCode'])) {
-          $errorMessage = $data['message'];
-          die($errorMessage);
-        }
-      } else {
-           //show information regarding the error
-           $errorMessage = "Error: LinkedIn server response code: " . $info2['http_code'] . " - ";
-           $errorMessage = $errorMessage . curl_error($conn2);
-           curl_close($conn2);
-           die($errorMessage);
-      }
-        // Create new user
-        $user = new RAP\User();
-
-        $identity = new RAP\Identity(RAP\Identity::LINKEDIN);
-        $identity->email = $data2['elements'][0]['handle~']['emailAddress'];
-        $identity->name = $data['localizedFirstName'];
-        $identity->surname = $data['localizedLastName'];
-        $identity->typedId = $typedId;
-
-        $user->addIdentity($identity);
-
-        $session->userToLogin = $user;
-        $session->save();
-        header('Location: ' . $BASE_PATH . '/tou-check');
-        die();
-    }
-
-    $auditLog->info("LOGIN,LinkedIn," . $user->id);
-    $callbackHandler->manageLoginRedirect($user, $session);
-} else {
-    //show information regarding the error
-    $errorMessage = "Error: LinkedIn server response code: " . $info2['http_code'] . " - ";
-    $errorMessage = $errorMessage . curl_error($conn2);
-    curl_close($conn2);
-    die($errorMessage);
-}
-?>
diff --git a/auth/saml2/aai.php b/auth/saml2/aai.php
deleted file mode 100644
index 0b8277958298ca17ce7f111d99336cdf8c878078..0000000000000000000000000000000000000000
--- a/auth/saml2/aai.php
+++ /dev/null
@@ -1,71 +0,0 @@
-<?php
-
-/* ----------------------------------------------------------------------------
- *               INAF - National Institute for Astrophysics
- *               IRA  - Radioastronomical Institute - Bologna
- *               OATS - Astronomical Observatory - Trieste
- * ----------------------------------------------------------------------------
- *
- * Copyright (C) 2016 Istituto Nazionale di Astrofisica
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License Version 3 as published by the
- * Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
- * details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program; if not, write to the Free Software Foundation, Inc., 51
- * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-/* This page MUST be protected by Shibboleth authentication 
- * On Apache httpd:
- * AuthType shibboleth
- * ShibRequestSetting requireSession 1
- * Require valid-user
- */
-
-include '../../include/init.php';
-startSession();
-
-if (isset($_SERVER['Shib-Session-ID'])) {
-
-    // Retrieving eduPersonPrincipalName (eppn)
-    $eppn = $_SERVER['eppn'];
-
-    // Search if the user is already registered into RAP using the eppn.
-    // The persistent id should be a more appropriate identifier, however at IA2
-    // we need to import all INAF user into RAP, even if they will never register,
-    // and in that case we know only their eppn.
-    $user = $userHandler->findUserByIdentity(RAP\Identity::EDU_GAIN, $eppn);
-
-    if ($user === null) {
-        // Creating a new user
-        $user = new RAP\User();
-
-        $identity = new RAP\Identity(RAP\Identity::EDU_GAIN);
-        $identity->email = $_SERVER['mail'];
-        $identity->name = $_SERVER['givenName'];
-        $identity->surname = $_SERVER['sn'];
-        $identity->typedId = $eppn;
-        $identity->eppn = $eppn;
-        //$_SERVER['Shib-Identity-Provider']
-
-        $user->addIdentity($identity);
-
-        $session->userToLogin = $user;
-        $session->save();
-        header('Location: ' . $BASE_PATH . '/tou-check');
-        die();
-    }
-
-    $auditLog->info("LOGIN,eduGAIN," . $user->id);
-    $callbackHandler->manageLoginRedirect($user, $session);
-} else {
-    http_response_code(500);
-    die("Shib-Session-ID not found!");
-}
diff --git a/auth/x509/certlogin.php b/auth/x509/certlogin.php
deleted file mode 100644
index c811cd83f49fbd3d9df74134901c9fca3f38120c..0000000000000000000000000000000000000000
--- a/auth/x509/certlogin.php
+++ /dev/null
@@ -1,69 +0,0 @@
-<?php
-
-/* ----------------------------------------------------------------------------
- *               INAF - National Institute for Astrophysics
- *               IRA  - Radioastronomical Institute - Bologna
- *               OATS - Astronomical Observatory - Trieste
- * ----------------------------------------------------------------------------
- *
- * Copyright (C) 2016 Istituto Nazionale di Astrofisica
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License Version 3 as published by the
- * Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
- * details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program; if not, write to the Free Software Foundation, Inc., 51
- * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-/* This page must be protected by client certificate authentication 
- * On Apache httpd:
- * SSLVerifyClient require
- * SSLVerifyDepth 10
- * SSLOptions +ExportCertData
- */
-
-include '../../include/init.php';
-startSession();
-
-if (isset($_SERVER['SSL_CLIENT_VERIFY']) && isset($_SERVER['SSL_CLIENT_V_REMAIN']) &&
-        $_SERVER['SSL_CLIENT_VERIFY'] === 'SUCCESS' && $_SERVER['SSL_CLIENT_V_REMAIN'] > 0) {
-
-    $x509Data = RAP\X509Data::parse($_SERVER);
-
-    $user = $userHandler->findUserByIdentity(RAP\Identity::X509, $x509Data->serialNumber);
-
-    if ($user === null) {
-        /**
-         * We want to extract name and surname from the X.509 certificate, however X.509
-         * puts name and surname together (inside the CN field).
-         * If name and surname are single words it is possible to retrieve them splitting
-         * on the space character, otherwise the user has to choose the correct combination.
-         * In that case partial X.509 data is temporarily stored into the user session and
-         * the page views/x509-name-surname.php is shown to the user before completing the
-         * registration, in order to allow him/her selecting the correct name and surname.
-         */
-        if ($x509Data->name === null) {
-            $session->x509DataToRegister = $x509Data;
-            $session->save();
-            header('Location: ' . $BASE_PATH . '/x509-name-surname');
-        } else {
-            $session->userToLogin = $x509Data->toUser();
-            $session->save();
-            header('Location: ' . $BASE_PATH . '/tou-check');
-        }
-        die();
-    } else {
-        $auditLog->info("LOGIN,X.509," . $user->id);
-        $callbackHandler->manageLoginRedirect($user, $session);
-    }
-} else {
-    http_response_code(500);
-    die("Unable to verify client certificate");
-}
diff --git a/auth/x509/index.php b/auth/x509/index.php
new file mode 100644
index 0000000000000000000000000000000000000000..fa7755b39858b0a81183f527d7187652e3596b44
--- /dev/null
+++ b/auth/x509/index.php
@@ -0,0 +1,19 @@
+<?php
+
+/* It is necessary to use this index file inside /auth/x509 
+ * because mod_rewrite (used by the Flight framework to
+ * create a front controller) changes some of the SSL headers
+ * and SSL client certificate is not recognized anymore */
+
+chdir(dirname(__FILE__));
+
+include '../../include/init.php';
+// Session must be started after classes inclusion in order
+// to avoid __PHP_Incomplete_Class Object error
+session_start();
+
+$x509Login = new \RAP\X509Login($locator);
+$url = $x509Login->login();
+header("Location: $url");
+die();
+
diff --git a/classes/CallbackHandler.php b/classes/CallbackHandler.php
deleted file mode 100644
index 7fa4718f3bbc688259837928fd2bd0d627acdb2b..0000000000000000000000000000000000000000
--- a/classes/CallbackHandler.php
+++ /dev/null
@@ -1,142 +0,0 @@
-<?php
-
-/* ----------------------------------------------------------------------------
- *               INAF - National Institute for Astrophysics
- *               IRA  - Radioastronomical Institute - Bologna
- *               OATS - Astronomical Observatory - Trieste
- * ----------------------------------------------------------------------------
- *
- * Copyright (C) 2016 Istituto Nazionale di Astrofisica
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License Version 3 as published by the
- * Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
- * details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program; if not, write to the Free Software Foundation, Inc., 51
- * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-namespace RAP;
-
-/**
- * Manage callback URL validation and redirection
- */
-class CallbackHandler {
-
-    private $dao;
-    private $basePath;
-    private $callbacks;
-
-    public function __construct(DAO $dao, $basePath, $callbacks) {
-        $this->dao = $dao;
-        $this->basePath = $basePath;
-        $this->callbacks = $callbacks;
-    }
-
-    /**
-     * If a callback URL is not in the configured list we should return null.
-     */
-    public function filterCallbackURL($callbackURL) {
-        foreach ($this->callbacks as $callback) {
-            if ($callback['url'] === $callbackURL) {
-                return $callbackURL;
-            }
-        }
-        return null;
-    }
-
-    /**
-     * Each callback has a title and a logo in order to avoid confusion in users
-     * and show in which application they are logging in using RAP.
-     * @param type $callbackURL
-     * @return type the callback title or null if the callback URL is not listed
-     * in configuration file or it doesn't have a title.
-     */
-    public function getCallbackTitle($callbackURL) {
-
-        foreach ($this->callbacks as $callback) {
-            if ($callback['url'] === $callbackURL) {
-                return $callback['title'];
-            }
-        }
-
-        return null;
-    }
-
-    /**
-     * Each callback has a title and a logo in order to avoid confusion in users
-     * and show in which application they are logging in using RAP.
-     * @param type $callbackURL
-     * @return type the callback logo or null if the callback URL is not listed
-     * in configuration file or it doesn't have a logo.
-     */
-    public function getCallbackLogo($callbackURL) {
-
-        foreach ($this->callbacks as $callback) {
-            if ($callback['url'] === $callbackURL) {
-                if (array_key_exists('logo', $callback)) {
-                    return $callback['logo'];
-                } else {
-                    return null;
-                }
-            }
-        }
-
-        return null;
-    }
-
-    /**
-     * Each callback has a title,a logo and auth in order to avoid confusion in
-     * user and show in which application they are logging in using RAP.
-     * @param type $callbackURL
-     * @return type the callback auth or null if the callback URL is not listed
-     * in configuration file or it doesn't have a auth.
-     */
-    public function getCallbackAuth($callbackURL) {
-
-        foreach ($this->callbacks as $callback) {
-            if ($callback['url'] === $callbackURL) {
-                if (array_key_exists('auth', $callback)) {
-                    return $callback['auth'];
-                } else {
-                    return null;
-                }
-            }
-        }
-
-        return null;
-    }
-    public function manageLoginRedirect($user, SessionData $session) {
-
-        if ($session->getCallbackURL() === null) {
-            http_response_code(401);
-            die("Unauthorized callback URL");
-        }
-
-        if ($session->getCallbackURL() === $this->basePath . '/') {
-            // Login in session
-            $session->user = $user;
-            $session->save();
-            // Return to index
-            header('Location: ' . $this->basePath);
-            die();
-        } else {
-            // External login using token
-            header('Location: ' . $this->getLoginWithTokenURL($user->id, $session->getCallbackURL()));
-            die();
-        }
-    }
-
-    public function getLoginWithTokenURL($userId, $callbackURL) {
-        $token = Util::createNewToken();
-        $this->dao->createLoginToken($token, $userId);
-        return $callbackURL . '?token=' . $token;
-    }
-
-}
diff --git a/classes/ClientAuthChecker.php b/classes/ClientAuthChecker.php
new file mode 100644
index 0000000000000000000000000000000000000000..cacb8e3ceee6adb1e253cdcaaa899a76958c240f
--- /dev/null
+++ b/classes/ClientAuthChecker.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace RAP;
+
+/**
+ * RFC 6749 specify that in some situations the client must send an Authorization
+ * Basic header containing its credentials (access token in the authorization code
+ * flow and refresh token requests).
+ */
+class ClientAuthChecker {
+
+    private $locator;
+
+    public function __construct(Locator $locator) {
+        $this->locator = $locator;
+    }
+
+    public function validateClientAuth(): void {
+
+        $headers = apache_request_headers();
+
+        if (!isset($headers['Authorization'])) {
+            throw new UnauthorizedException("Missing Authorization header");
+        }
+
+        $authorizationHeader = explode(" ", $headers['Authorization']);
+        if ($authorizationHeader[0] === "Basic") {
+            $basic = explode(':', base64_decode($authorizationHeader[1]));
+            if (count($basic) !== 2) {
+                throw new BadRequestException("Malformed Basic-Auth header");
+            }
+            $clientId = $basic[0];
+            $clientSecret = $basic[1];
+
+            $client = $this->locator->getOAuth2ClientDAO()->getOAuth2ClientByClientId($clientId);
+            if ($client === null) {
+                throw new UnauthorizedException("Client '$clientId' not configured");
+            }
+            if ($clientSecret !== $client->secret) {
+                throw new UnauthorizedException("Invalid client secret");
+            }
+        } else {
+            throw new UnauthorizedException("Expected Basic authorization header");
+        }
+    }
+
+}
diff --git a/classes/JWKSHandler.php b/classes/JWKSHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..feffd906d1ac26d6c27e3ae528ffcf8461773efb
--- /dev/null
+++ b/classes/JWKSHandler.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace RAP;
+
+use phpseclib\Crypt\RSA;
+
+/**
+ * Manages the JWT Key Sets (currently only RSA).
+ */
+class JWKSHandler {
+
+    private $locator;
+
+    public function __construct(Locator $locator) {
+        $this->locator = $locator;
+    }
+
+    public function generateKeyPair() {
+
+        $rsa = new RSA();
+
+        $rsa->setPrivateKeyFormat(RSA::PRIVATE_FORMAT_PKCS1);
+        $rsa->setPublicKeyFormat(RSA::PUBLIC_FORMAT_PKCS8);
+        // Guacamole needs a key of at least 2048
+        $result = $rsa->createKey(2048);
+
+        $keyPair = new RSAKeyPair();
+        $keyPair->alg = 'RS256';
+        $keyPair->privateKey = $result['privatekey'];
+        $keyPair->publicKey = $result['publickey'];
+        $keyPair->keyId = bin2hex(random_bytes(8));
+
+        $dao = $this->locator->getJWKSDAO();
+        $dao->insertRSAKeyPair($keyPair);
+
+        return $keyPair;
+    }
+
+    public function getJWKS() {
+
+        $dao = $this->locator->getJWKSDAO();
+
+        $keyPairs = $dao->getRSAKeyPairs();
+
+        $keys = [];
+        foreach ($keyPairs as $keyPair) {
+
+            $rsa = new RSA();
+            $rsa->loadKey($keyPair->publicKey);
+            $rsa->setPublicKey();
+            $publicKeyXML = $rsa->getPublicKey(RSA::PUBLIC_FORMAT_XML);
+
+            $rsaModulus = $this->getTagContent($publicKeyXML, "Modulus");
+            $rsaExponent = $this->getTagContent($publicKeyXML, "Exponent");
+
+            $urisafeModulus = strtr($rsaModulus, '+/', '-_');
+
+            $jwk = [];
+            $jwk['kty'] = "RSA";
+            $jwk['kid'] = $keyPair->keyId;
+            $jwk['use'] = "sig";
+            $jwk['n'] = $urisafeModulus;
+            $jwk['e'] = $rsaExponent;
+
+            array_push($keys, $jwk);
+        }
+
+        return [
+            "keys" => $keys
+        ];
+    }
+
+    private function getTagContent(string $publicKeyXML, string $tagname): string {
+        $matches = [];
+        $pattern = "#<\s*?$tagname\b[^>]*>(.*?)</$tagname\b[^>]*>#s";
+        preg_match($pattern, $publicKeyXML, $matches);
+        return $matches[1];
+    }
+
+}
diff --git a/classes/Locator.php b/classes/Locator.php
new file mode 100644
index 0000000000000000000000000000000000000000..d032f70b14cbb85d0c17ab8eeed2441df8935fad
--- /dev/null
+++ b/classes/Locator.php
@@ -0,0 +1,152 @@
+<?php
+
+namespace RAP;
+
+/**
+ * Class implementing the locator pattern in order to implement a rough dependency injection.
+ */
+class Locator {
+
+    public $config;
+    private $serviceLogger;
+    private $auditLogger;
+    private $session;
+    private $version;
+
+    public function __construct($config) {
+        $this->config = $config;
+
+        $this->setupLoggers();
+        $this->version = file_get_contents(ROOT . '/version.txt');
+    }
+
+    public function getVersion(): string {
+        return $this->version;
+    }
+
+    public function getProtocol(): string {
+        return (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https://' : 'http://';
+    }
+
+    public function getBasePath(): string {
+        return $this->getProtocol() . $_SERVER['HTTP_HOST'] . $this->config->contextRoot;
+    }
+
+    public function getUserDAO(): UserDAO {
+        $databaseConfig = $this->config->databaseConfig;
+        switch ($databaseConfig->dbtype) {
+            case 'MySQL':
+                return new MySQLUserDAO($this);
+            default:
+                throw new \Exception($databaseConfig->dbtype . ' not supported yet');
+        }
+    }
+
+    public function getOAuth2ClientDAO(): OAuth2ClientDAO {
+        $databaseConfig = $this->config->databaseConfig;
+        switch ($databaseConfig->dbtype) {
+            case 'MySQL':
+                return new MySQLOAuth2ClientDAO($this);
+            default:
+                throw new \Exception($databaseConfig->dbtype . ' not supported yet');
+        }
+    }
+
+    public function getJWKSDAO(): JWKSDAO {
+        $databaseConfig = $this->config->databaseConfig;
+        switch ($databaseConfig->dbtype) {
+            case 'MySQL':
+                return new MySQLJWKSDAO($this);
+            default:
+                throw new \Exception($databaseConfig->dbtype . ' not supported yet');
+        }
+    }
+
+    public function getAccessTokenDAO(): AccessTokenDAO {
+        $databaseConfig = $this->config->databaseConfig;
+        switch ($databaseConfig->dbtype) {
+            case 'MySQL':
+                return new MySQLAccessTokenDAO($this);
+            default:
+                throw new \Exception($databaseConfig->dbtype . ' not supported yet');
+        }
+    }
+
+    public function getRefreshTokenDAO(): RefreshTokenDAO {
+        $databaseConfig = $this->config->databaseConfig;
+        switch ($databaseConfig->dbtype) {
+            case 'MySQL':
+                return new MySQLRefreshTokenDAO($this);
+            default:
+                throw new \Exception($databaseConfig->dbtype . ' not supported yet');
+        }
+    }
+
+    public function getCallbackHandler(): CallbackHandler {
+        return new CallbackHandler($this);
+    }
+
+    public function getUserHandler(): UserHandler {
+        return new UserHandler($this);
+    }
+
+    public function getMailSender(): MailSender {
+        return new MailSender($_SERVER['HTTP_HOST'], $this->getBasePath());
+    }
+
+    public function getOAuth2RequestHandler(): OAuth2RequestHandler {
+        return new OAuth2RequestHandler($this);
+    }
+
+    public function getTokenBuilder(): TokenBuilder {
+        return new TokenBuilder($this);
+    }
+
+    public function getTokenChecker(): TokenChecker {
+        return new TokenChecker($this);
+    }
+
+    public function getClientAuthChecker(): ClientAuthChecker {
+        return new ClientAuthChecker($this);
+    }
+
+    /**
+     * Retrieve the SessionData object from the $_SESSION PHP variable. Create a
+     * new one if it is necessary.
+     */
+    public function getSession(): SessionData {
+        if (isset($_SESSION[\RAP\SessionData::KEY])) {
+            $this->session = $_SESSION[SessionData::KEY];
+        } else {
+            $this->session = new \RAP\SessionData();
+            $this->session->save();
+        }
+        return $this->session;
+    }
+
+    public function getServiceLogger(): \Monolog\Logger {
+        return $this->serviceLogger;
+    }
+
+    public function getAuditLogger(): \Monolog\Logger {
+        return $this->auditLogger;
+    }
+
+    public function getJWKSHandler(): JWKSHandler {
+        return new JWKSHandler($this);
+    }
+
+    private function setupLoggers() {
+        // Monolog require timezone to be set
+        date_default_timezone_set($this->config->timeZone);
+
+        $logLevel = array_search($this->config->logLevel, \Monolog\Logger::getLevels());
+
+        $this->serviceLogger = new \Monolog\Logger('serviceLogger');
+        $this->serviceLogger->pushHandler(new \Monolog\Handler\StreamHandler($this->config->serviceLogFile, $logLevel));
+
+        $this->auditLogger = new \Monolog\Logger('auditLogger');
+        $this->auditLogger->pushHandler(new \Monolog\Handler\StreamHandler($this->config->auditLogFile, $logLevel));
+    }
+
+}
diff --git a/classes/MailBodyBuilder.php b/classes/MailBodyBuilder.php
deleted file mode 100644
index 6c8baba044c186ba8d93ae8bd1f8dd4402411ff8..0000000000000000000000000000000000000000
--- a/classes/MailBodyBuilder.php
+++ /dev/null
@@ -1,160 +0,0 @@
-<?php
-
-namespace RAP;
-
-/**
- * This class is used to build an e-mail message body both in HTML and in its
- * equivalent plaintext.
- */
-class MailBodyBuilder {
-
-    private $htmlBody;
-    private $textBody;
-    private $openedHTMLTags;
-    private $editable;
-
-    function __construct() {
-        $this->htmlBody = "";
-        $this->textBody = "";
-        $this->openedHTMLTags = [];
-        $this->editable = true;
-    }
-
-    private function checkEditable() {
-        if (!$this->editable) {
-            throw new \Exception("You cannot edit the body after it has been generated");
-        }
-    }
-
-    public function addText($text) {
-        $this->checkEditable();
-        $this->htmlBody .= $text;
-        $this->textBody .= $text;
-        return $this;
-    }
-
-    public function addLineBreak() {
-        $this->checkEditable();
-        $this->htmlBody .= "<br>\n";
-        $this->textBody .= "\n";
-        return $this;
-    }
-
-    public function addHr() {
-        $this->checkEditable();
-        $this->htmlBody .= "<hr/>\n";
-        $this->textBody .= "\n---------------------\n";
-        return $this;
-    }
-
-    public function addLinkWithDescription($url, $text) {
-        $this->checkEditable();
-        $this->htmlBody .= '<a href="' . $url . '" target="blank_">' . $text . "</a>\n";
-        $this->textBody .= $text . " ( " . $url . " ) ";
-        return $this;
-    }
-
-    public function addLink($url) {
-        $this->checkEditable();
-        $this->htmlBody .= '<a href="' . $url . '" target="blank_">' . $url . "</a>\n";
-        $this->textBody .= $url . " ";
-        return $this;
-    }
-
-    public function addEmailAddress($email, $description) {
-        $this->checkEditable();
-        $this->htmlBody .= '<a href="mailto:' . $email . '">' . $description . '</a>';
-        $this->textBody .= $description . " (" . $email . ")";
-        return $this;
-    }
-
-    private function openHTMLTag($tag, $equivalentPlainText) {
-        $this->checkEditable();
-        if (in_array($tag, $this->openedHTMLTags)) {
-            throw new \Exception("You are already inside a " . $tag . " tag!");
-        }
-        $this->openedHTMLTags[] = $tag;
-        $this->htmlBody .= "<" . $tag . ">";
-        $this->textBody .= $equivalentPlainText;
-        return $this;
-    }
-
-    private function closeHTMLTag($tag, $equivalentPlainText) {
-        $this->checkEditable();
-        if ($this->openedHTMLTags[count($this->openedHTMLTags) - 1] !== $tag) {
-            throw new \Exception("You are not inside a " . $tag . " tag!");
-        }
-        array_pop($this->openedHTMLTags);
-        $this->htmlBody .= "</" . $tag . ">";
-        $this->textBody .= $equivalentPlainText;
-        return $this;
-    }
-
-    public function startBold() {
-        return $this->openHTMLTag("strong", "*");
-    }
-
-    public function endBold() {
-        return $this->closeHTMLTag("strong", "*");
-    }
-
-    public function startParagraph() {
-        return $this->openHTMLTag("p", "");
-    }
-
-    public function endParagraph() {
-        return $this->closeHTMLTag("p", "\n");
-    }
-
-    public function startList() {
-        return $this->openHTMLTag("ul", "\n");
-    }
-
-    public function startListItem() {
-        return $this->openHTMLTag("li", " * ");
-    }
-
-    public function endListItem() {
-        return $this->closeHTMLTag("li", "\n");
-    }
-
-    public function endList() {
-        return $this->closeHTMLTag("ul", "\n");
-    }
-
-    private function checkEnd() {
-        if (count($this->openedHTMLTags) > 0) {
-            $unclosedTags = "";
-            foreach ($this->openedHTMLTags as $tag) {
-                $unclosedTags .= $tag . " ";
-            }
-            throw new \Exception("You must close all tags before generating email body! Unclosed tags: " . $unclosedTags);
-        }
-    }
-
-    private function setCompatibleLineBreaks($value) {
-        return str_replace("\n", "\n\r", $value);
-    }
-
-    private function finalizeBodyIfNecessary() {
-        if ($this->editable) {
-            $this->checkEnd();
-
-            $this->htmlBody = $this->setCompatibleLineBreaks($this->htmlBody);
-            $this->textBody = $this->setCompatibleLineBreaks($this->textBody);
-
-            $this->editable = false;
-        }
-    }
-
-    public function getTextPlainBody() {
-        $this->finalizeBodyIfNecessary();
-        return $this->textBody;
-    }
-
-    public function getHTMLBody() {
-        $this->finalizeBodyIfNecessary();
-        return $this->htmlBody;
-    }
-
-}
diff --git a/classes/MailSender.php b/classes/MailSender.php
deleted file mode 100644
index 5e8c7d434eb48ab9d44bdbbd37a284054d2d2c37..0000000000000000000000000000000000000000
--- a/classes/MailSender.php
+++ /dev/null
@@ -1,144 +0,0 @@
-<?php
-
-/* ----------------------------------------------------------------------------
- *               INAF - National Institute for Astrophysics
- *               IRA  - Radioastronomical Institute - Bologna
- *               OATS - Astronomical Observatory - Trieste
- * ----------------------------------------------------------------------------
- *
- * Copyright (C) 2016 Istituto Nazionale di Astrofisica
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License Version 3 as published by the
- * Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
- * details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program; if not, write to the Free Software Foundation, Inc., 51
- * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-namespace RAP;
-
-use \PHPMailer\PHPMailer\PHPMailer;
-
-/**
- * Manage mail sending.
- * Currently used only for join email messages.
- */
-class MailSender {
-
-    private $serverName;
-    private $basePath;
-    private $mbb;
-
-    public function __construct($serverName, $basePath) {
-        $this->serverName = $serverName;
-        $this->basePath = $basePath;
-        $this->mbb = new MailBodyBuilder();
-    }
-
-    private function addDescriptionItem($key, $value) {
-        $this->mbb->startBold()
-                ->addText($key)
-                ->endBold()
-                ->addText(": " . $value)
-                ->addLineBreak();
-    }
-
-    /**
-     * Send the email for confirming the join request.
-     * @param \RAP\User $recipientUser user target of the join requests: he/she
-     * will receive the email containing the confirmation link
-     * @param \RAP\User $applicantUser user that have requested the join
-     * @param string $token the join token
-     */
-    public function sendJoinEmail(User $recipientUser, User $applicantUser, $token) {
-
-        global $auditLog;
-
-        $confirmJoinURL = $this->basePath . '/confirm-join?token=' . $token;
-
-        $this->mbb->startParagraph()
-                ->addText("Dear IA2 user,")
-                ->addLineBreak()
-                ->addText("the following user requested to join your accounts on the ")
-                ->addLinkWithDescription("https://sso.ia2.inaf.it/rap-ia2/", "RAP facility")
-                ->addText(":")
-                ->endParagraph();
-
-        foreach ($applicantUser->identities as $identity) {
-
-            $this->addDescriptionItem("Type", $identity->type);
-            if ($identity->name !== null) {
-                $this->addDescriptionItem("Name", $identity->name);
-            }
-            if ($identity->surname !== null) {
-                $this->addDescriptionItem("Surname", $identity->surname);
-            }
-            $this->addDescriptionItem("E-mail", $identity->email);
-            if ($identity->eppn !== null) {
-                $this->addDescriptionItem("Eppn", $identity->eppn);
-            }
-            if ($identity->institution !== null) {
-                $this->addDescriptionItem("Institution", $identity->institution);
-            }
-
-            $this->mbb->addLineBreak();
-        }
-
-        $this->mbb->startParagraph()
-                ->addText("If you and this user are ")
-                ->startBold()
-                ->addText("the same person")
-                ->endBold()
-                ->addText(" click on the following link for joining your accounts: ")
-                ->addLink($confirmJoinURL)
-                ->addLineBreak()
-                ->addText("Otherwise you can ignore this email.")
-                ->endParagraph()
-                //
-                ->startParagraph()
-                ->startBold()
-                ->addText("Please don't use this functionality for sharing resources between your coworkers")
-                ->endBold()
-                ->addText(", use ")
-                ->addLinkWithDescription("https://sso.ia2.inaf.it/grouper", "Grouper")
-                ->addText(" for that.")
-                ->endParagraph()
-                //
-                ->addLineBreak()
-                ->startBold()
-                ->addText("*** This is an automatically generated email, please do not reply to this message ***")
-                ->endBold()
-                ->addLineBreak()
-                ->addText("If you need information please contact ")
-                ->addEmailAddress("ia2@oats.inaf.it", "IA2 Staff");
-
-        $mail = new PHPMailer(true); // Passing `true` enables exceptions
-        try {
-
-            $toAddress = $recipientUser->getPrimaryEmail();
-
-            $mail->isSMTP();
-            $mail->Port = 25;
-            $mail->setFrom("noreply@" . $this->serverName, 'IA2 SSO');
-            $mail->addAddress($toAddress);
-            $mail->CharSet = 'utf-8';
-            $mail->Subject = "IA2 RAP: Join request";
-            $mail->Body = $this->mbb->getHTMLBody();
-            $mail->AltBody = $this->mbb->getTextPlainBody();
-
-            $auditLog->info("JOIN email. Sending to " . $toAddress);
-            $mail->send();
-        } catch (\Exception $ex) {
-            error_log($ex->getMessage());
-            throw $ex;
-        }
-    }
-
-}
diff --git a/classes/OAuth2RequestHandler.php b/classes/OAuth2RequestHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..12da4751b44284126ad32fe8364d3072984e844d
--- /dev/null
+++ b/classes/OAuth2RequestHandler.php
@@ -0,0 +1,279 @@
+<?php
+
+namespace RAP;
+
+class OAuth2RequestHandler {
+
+    private $locator;
+
+    public function __construct(\RAP\Locator $locator) {
+        $this->locator = $locator;
+    }
+
+    public function handleAuthorizeRequest($params) {
+
+        if ($params['client_id'] === null) {
+            throw new BadRequestException("Client id is required");
+        }
+
+        if ($params['redirect_uri'] === null) {
+            throw new BadRequestException("Redirect URI is required");
+        }
+
+        $client = $this->locator->getOAuth2ClientDAO()->getOAuth2ClientByClientId($params['client_id']);
+        if ($client === null) {
+            throw new BadRequestException("Invalid client id: " . $params['client_id']);
+        }
+        if ($client->redirectUrl !== $params['redirect_uri']) {
+            throw new BadRequestException("Invalid client redirect URI: " . $params['redirect_uri']);
+        }
+
+        $alg = $params['alg'];
+        if ($alg === null) {
+            $alg = "RS256";
+        }
+
+        $state = $params['state'];
+        $nonce = $params['nonce'];
+
+        if ($state === null && $nonce === null) {
+            throw new BadRequestException("State or nonce is required");
+        }
+
+        // Storing OAuth2 data in session
+        $oauth2Data = new OAuth2RequestData();
+        $oauth2Data->clientId = $client->client;
+        $oauth2Data->redirectUrl = $client->redirectUrl;
+        $oauth2Data->state = $state;
+        $oauth2Data->nonce = $nonce;
+
+        $scope = $params['scope'];
+        if ($scope !== null) {
+            $oauth2Data->scope = explode(' ', $scope);
+        }
+
+        $session = $this->locator->getSession();
+        $session->setOAuth2RequestData($oauth2Data);
+    }
+
+    public function getRedirectResponseUrl(): string {
+
+        $session = $this->locator->getSession();
+
+        $code = base64_encode(bin2hex(openssl_random_pseudo_bytes(64)));
+
+        $tokenData = new AccessTokenData();
+        // Code is stored in hashed format inside the database, as a basic
+        // security measure in order to prevent issues in case of data breach.
+        $tokenData->codeHash = hash('sha256', $code);
+        $tokenData->userId = $session->getUser()->id;
+        $tokenData->clientId = $session->getOAuth2RequestData()->clientId;
+        $tokenData->redirectUri = $session->getOAuth2RequestData()->redirectUrl;
+        $tokenData->scope = $session->getOAuth2RequestData()->scope;
+
+        $this->locator->getAccessTokenDAO()->createTokenData($tokenData);
+
+        $state = $session->getOAuth2RequestData()->state;
+        $nonce = $session->getOAuth2RequestData()->nonce;
+
+        if ($state !== null) {
+            // Authorization code grant flow
+            $redirectUrl = $session->getOAuth2RequestData()->redirectUrl
+                    . '?code=' . $code . '&scope=profile&state=' . $state;
+        } else {
+            // Implicit grant flow
+            $idToken = $this->locator->getTokenBuilder()->getIdToken($tokenData, function(& $jwt) use($nonce) {
+                $jwt['nonce'] = $nonce;
+            });
+            $redirectUrl = $session->getOAuth2RequestData()->redirectUrl . "#id_token=" . $idToken;
+        }
+
+        return $redirectUrl;
+    }
+
+    public function handleAccessTokenRequest($params): array {
+
+        $this->locator->getClientAuthChecker()->validateClientAuth();
+
+        if ($params['code'] === null) {
+            throw new BadRequestException("code id is required");
+        }
+
+        if ($params['redirect_uri'] === null) {
+            throw new BadRequestException("Redirect URI is required");
+        }
+
+        // Note: theorically the standard wants also the client_id here,
+        // however some clients don't send it (e.g. Spring Security library)
+        //
+        $codeHash = hash('sha256', $params['code']);
+
+        $tokenData = $this->locator->getAccessTokenDAO()->retrieveTokenDataFromCode($codeHash);
+
+        if ($tokenData === null) {
+            throw new BadRequestException("No token for given code");
+        }
+
+        if ($tokenData->redirectUri !== $params['redirect_uri']) {
+            throw new BadRequestException("Invalid redirect URI: " . $params['redirect_uri']);
+        }
+
+        $response = $this->getAccessTokenResponse($tokenData);
+        $this->locator->getAccessTokenDAO()->deleteTokenData($codeHash);
+        return $response;
+    }
+
+    public function handleRefreshTokenRequest($params): array {
+
+        $this->locator->getClientAuthChecker()->validateClientAuth();
+
+        if ($params['refresh_token'] === null) {
+            throw new BadRequestException("refresh_token is required");
+        }
+
+        $tokenHash = hash('sha256', $params['refresh_token']);
+        $refreshToken = $this->locator->getRefreshTokenDAO()->getRefreshTokenData($tokenHash);
+
+        if ($refreshToken === null || $refreshToken->isExpired()) {
+            throw new UnauthorizedException("Invalid refresh token");
+        }
+
+        $scope = $this->getScope($params, $refreshToken);
+
+        // Generating a new access token
+        $accessTokenData = new AccessTokenData();
+        $accessTokenData->clientId = $refreshToken->clientId;
+        $accessTokenData->userId = $refreshToken->userId;
+        $accessTokenData->scope = $scope;
+
+        $accessTokenData = $this->locator->getAccessTokenDAO()->createTokenData($accessTokenData);
+
+        return $this->getAccessTokenResponse($accessTokenData);
+    }
+
+    /**
+     * We can request a new access token with a scope that is a subset (or the
+     * same set) of the scope defined for the refresh token.
+     */
+    private function getScope(array $params, RefreshTokenData $refreshToken): ?array {
+
+        $scope = $refreshToken->scope;
+
+        if ($params['scope'] !== null) {
+
+            $newScopeValues = explode(' ', $params['scope']);
+
+            foreach ($newScopeValues as $newScopeValue) {
+                $found = false;
+                foreach ($scope as $oldScopeValue) {
+                    if ($oldScopeValue === $newScopeValue) {
+                        $found = true;
+                        break;
+                    }
+                }
+                if (!$found) {
+                    throw new BadRequestException("Scope " . $newScopeValue . " was not defined for the given refresh token");
+                }
+            }
+
+            $scope = $newScopeValues;
+        }
+
+        return $scope;
+    }
+
+    private function getAccessTokenResponse(AccessTokenData $tokenData) {
+
+        $result = [];
+        $result['access_token'] = $this->locator->getTokenBuilder()->getAccessToken($tokenData);
+        $result['token_type'] = 'Bearer';
+        $result['expires_in'] = $tokenData->expirationTime - time();
+
+        $result['refresh_token'] = $this->buildRefreshToken($tokenData);
+
+        if ($tokenData->scope !== null && in_array('openid', $tokenData->scope)) {
+            $result['id_token'] = $this->locator->getTokenBuilder()->getIdToken($tokenData);
+        }
+
+        return $result;
+    }
+
+    /**
+     * Token introspection endpoint shouldn't be necessary when using OIDC (since
+     * tokens are self-contained JWT). This function is kept here for compatibility
+     * with some libraries (e.g. Spring Security) but it could be removed in the
+     * future.
+     */
+    public function handleCheckTokenRequest(): array {
+
+        $jwt = $this->locator->getTokenChecker()->validateToken();
+        $tokenData = $this->getTokenDataFromJwtObject($jwt);
+
+        $result = [];
+        $result['exp'] = $tokenData->expirationTime - time();
+        $result['user_name'] = $tokenData->userId;
+        $result['client_id'] = $tokenData->clientId;
+        $result['access_token'] = $this->copyReceivedAccessToken();
+        $result['refresh_token'] = $this->buildRefreshToken($tokenData);
+
+        if (isset($tokenData->scope) && count($tokenData->scope) > 0) {
+            $result['scope'] = $tokenData->scope;
+            if (in_array('openid', $tokenData->scope)) {
+                $result['id_token'] = $this->locator->getTokenBuilder()->getIdToken($tokenData);
+            }
+        }
+
+        return $result;
+    }
+
+    private function copyReceivedAccessToken(): string {
+        $headers = apache_request_headers();
+        return explode(" ", $headers['Authorization'])[1];
+    }
+
+    private function getTokenDataFromJwtObject($jwt): AccessTokenData {
+
+        $tokenData = new AccessTokenData();
+        $tokenData->clientId = $this->getClientIdFromAudience($jwt);
+        $tokenData->userId = $jwt->sub;
+        $tokenData->creationTime = $jwt->iat;
+        $tokenData->expirationTime = $jwt->exp;
+        $tokenData->scope = explode(' ', $jwt->scope);
+        return $tokenData;
+    }
+
+    private function getClientIdFromAudience(object $jwt): string {
+
+        if (!(isset($jwt->aud))) {
+            throw new UnauthorizedException("Missing 'aud' claim in token");
+        }
+
+        $audience = $jwt->aud;
+        if (is_array($audience)) {
+            if (count($audience) === 0) {
+                throw new UnauthorizedException("Token has empty audience");
+            }
+            return $audience[0];
+        }
+        return $audience;
+    }
+
+    private function buildRefreshToken(AccessTokenData $tokenData): string {
+        $refreshToken = base64_encode(bin2hex(openssl_random_pseudo_bytes(128)));
+        $refreshTokenHash = hash('sha256', $refreshToken);
+        $this->storeRefreshTokenData($tokenData, $refreshTokenHash);
+        return $refreshToken;
+    }
+
+    private function storeRefreshTokenData(AccessTokenData $accessTokenData, string $refreshTokenHash): void {
+
+        $refreshToken = new RefreshTokenData();
+        $refreshToken->tokenHash = $refreshTokenHash;
+        $refreshToken->clientId = $accessTokenData->clientId;
+        $refreshToken->userId = $accessTokenData->userId;
+        $refreshToken->scope = $accessTokenData->scope;
+
+        $this->locator->getRefreshTokenDAO()->createRefreshTokenData($refreshToken);
+    }
+
+}
diff --git a/classes/SessionData.php b/classes/SessionData.php
deleted file mode 100644
index 97f0d25b831f27c53a7d53a5f66e6937bb908bf7..0000000000000000000000000000000000000000
--- a/classes/SessionData.php
+++ /dev/null
@@ -1,132 +0,0 @@
-<?php
-
-/* ----------------------------------------------------------------------------
- *               INAF - National Institute for Astrophysics
- *               IRA  - Radioastronomical Institute - Bologna
- *               OATS - Astronomical Observatory - Trieste
- * ----------------------------------------------------------------------------
- *
- * Copyright (C) 2016 Istituto Nazionale di Astrofisica
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License Version 3 as published by the
- * Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
- * details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program; if not, write to the Free Software Foundation, Inc., 51
- * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-namespace RAP;
-
-/**
- * This class wraps various objects that need to be stored into the session in
- * order to provide an object oriented transparent session management.
- */
-class SessionData {
-
-    private $dao;
-    private $callbackURL;
-    private $callbackTitle;
-    private $callbackLogo;
-    private $callbackAuth;
-    public $user;
-    public $userSearchResults;
-    public $x509DataToRegister;
-    // user which is going to perform the login (we need to store this in the
-    // session because we need to check the Terms of Use user consensus, so we
-    // redirect to another page after retrieving user data.
-    public $userToLogin;
-
-    /**
-     * @todo: move DAO away from here
-     */
-    public function __construct(DAO $dao) {
-        $this->dao = $dao;
-    }
-
-    /**
-     * Store the data into the $_SESSION PHP variable
-     */
-    public function save() {
-        $_SESSION['SessionData'] = $this;
-    }
-
-    /**
-     * Retrieve the SessionData object from the $_SESSION PHP variable. Create a
-     * new one if it is necessary.
-     * @param \RAP\DAO $dao
-     * @return \RAP\SessionData the SessionData object
-     */
-    public static function get(DAO $dao) {
-
-        if (!isset($_SESSION['SessionData'])) {
-            $session = new SessionData($dao);
-            $session->save();
-        }
-        return $_SESSION['SessionData'];
-    }
-
-    public function setCallbackURL(CallbackHandler $callbackHandler, $callbackURL) {
-        $this->callbackURL = $callbackHandler->filterCallbackURL($callbackURL);
-        $this->callbackTitle = $callbackHandler->getCallbackTitle($callbackURL);
-        $this->callbackLogo = $callbackHandler->getCallbackLogo($callbackURL);
-        $this->callbackAuth = $callbackHandler->getCallbackAuth($callbackURL);
-        $this->save();
-    }
-
-    public function getCallbackURL() {
-        return $this->callbackURL;
-    }
-
-    public function getCallbackTitle() {
-        return $this->callbackTitle;
-    }
-
-    public function getCallbackLogo() {
-        return $this->callbackLogo;
-    }
-
-    public function getCallbackAuth() {
-        return $this->callbackAuth;
-    }
-
-    /**
-     * Perform a user search and store the results inside the session. This is
-     * used for achieving the user selection using the dropdown menu in the join
-     * request modal.
-     * @param string $searchText
-     */
-    public function searchUser($searchText) {
-        $users = $this->dao->searchUser($searchText);
-
-        $this->userSearchResults = [];
-        foreach ($users as $user) {
-            // this search shouldn't contains the user itself
-            if ($user->id !== $this->user->id) {
-                $searchResult = UserSearchResult::buildFromUser($user);
-                array_push($this->userSearchResults, $searchResult);
-            }
-        }
-
-        $this->save();
-    }
-
-    /**
-     * Update the user data model stored into the session after the primary
-     * identity has changed, in order to avoid reading again the user data from
-     * the database.
-     * @param int $identityId
-     */
-    public function updatePrimaryIdentity($identityId) {
-        foreach ($this->user->identities as $identity) {
-            $identity->primary = ($identity->id === $identityId);
-        }
-    }
-
-}
diff --git a/classes/TokenBuilder.php b/classes/TokenBuilder.php
new file mode 100644
index 0000000000000000000000000000000000000000..abdb369f1610fe423232f3d0a217689bf334ab98
--- /dev/null
+++ b/classes/TokenBuilder.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace RAP;
+
+use \Firebase\JWT\JWT;
+
+class TokenBuilder {
+
+    private $locator;
+
+    public function __construct(Locator $locator) {
+        $this->locator = $locator;
+    }
+
+    public function getIdToken(AccessTokenData $tokenData, \Closure $jwtCustomizer = null): string {
+
+        $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair();
+
+        $payload = $this->createIdTokenPayloadArray($tokenData, $jwtCustomizer);
+
+        return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId);
+    }
+
+    private function createIdTokenPayloadArray(AccessTokenData $tokenData, \Closure $jwtCustomizer = null) {
+
+        $user = $this->locator->getUserDAO()->findUserById($tokenData->userId);
+
+        $payloadArr = array(
+            'iss' => $this->locator->config->jwtIssuer,
+            'sub' => strval($user->id),
+            'iat' => intval($tokenData->creationTime),
+            'exp' => intval($tokenData->expirationTime),
+            'name' => $user->getCompleteName(),
+            'aud' => $tokenData->clientId
+        );
+
+        if (in_array("email", $tokenData->scope)) {
+            $payloadArr['email'] = $user->getPrimaryEmail();
+        }
+        if (in_array("profile", $tokenData->scope)) {
+            $payloadArr['given_name'] = $user->getName();
+            $payloadArr['family_name'] = $user->getSurname();
+            if ($user->getInstitution() !== null) {
+                $payloadArr['org'] = $user->getInstitution();
+            }
+        }
+
+        if ($jwtCustomizer !== null) {
+            // Add additional custom claims
+            $jwtCustomizer($payloadArr);
+        }
+
+        return $payloadArr;
+    }
+
+    public function getAccessToken(AccessTokenData $tokenData, \Closure $jwtCustomizer = null) {
+
+        $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair();
+
+        $user = $this->locator->getUserDAO()->findUserById($tokenData->userId);
+
+        $payload = array(
+            'iss' => $this->locator->config->jwtIssuer,
+            'sub' => strval($user->id),
+            'iat' => intval($tokenData->creationTime),
+            'exp' => intval($tokenData->expirationTime),
+            'aud' => $this->getAudience($tokenData),
+            'scope' => implode(' ', $tokenData->scope)
+        );
+        if ($jwtCustomizer !== null) {
+            // Add additional custom claims
+            $jwtCustomizer($payload);
+        }
+
+        return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId);
+    }
+
+    private function getAudience(AccessTokenData $tokenData) {
+
+        $client = $this->locator->getOAuth2ClientDAO()->getOAuth2ClientByClientId($tokenData->clientId);
+
+        $audiences = [$tokenData->clientId];
+
+        foreach ($tokenData->scope as $scope) {
+            if (array_key_exists($scope, $client->scopeAudienceMap)) {
+                $audience = $client->scopeAudienceMap[$scope];
+                if (!in_array($audience, $audiences)) {
+                    array_push($audiences, $audience);
+                }
+            }
+        }
+
+        if (count($audiences) === 1) {
+            // according to RFC 7519 audience can be a single value or an array
+            return $audiences[0];
+        }
+        return $audiences;
+    }
+
+    /**
+     * @param int $lifespan in hours
+     * @param string $audience target service
+     */
+    public function generateNewToken(int $lifespan, string $audience) {
+        $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair();
+
+        $user = $this->locator->getSession()->getUser();
+
+        $iat = time();
+        $exp = $iat + $lifespan * 3600;
+
+        $payload = array(
+            'iss' => $this->locator->config->jwtIssuer,
+            'sub' => strval($user->id),
+            'iat' => $iat,
+            'exp' => $exp,
+            'aud' => $audience
+        );
+
+        return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId);
+    }
+
+}
diff --git a/classes/TokenChecker.php b/classes/TokenChecker.php
new file mode 100644
index 0000000000000000000000000000000000000000..370bc8526f6470d145f93e8798ce79d7aed4a74c
--- /dev/null
+++ b/classes/TokenChecker.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace RAP;
+
+use \Firebase\JWT\JWT;
+
+class TokenChecker {
+
+    private $locator;
+
+    public function __construct(Locator $locator) {
+        $this->locator = $locator;
+    }
+
+    public function validateToken(): object {
+        $headers = apache_request_headers();
+
+        if (!isset($headers['Authorization'])) {
+            throw new BadRequestException("Missing Authorization header");
+        }
+
+        $authorizationHeader = explode(" ", $headers['Authorization']);
+        if ($authorizationHeader[0] === "Bearer") {
+            $token = $authorizationHeader[1];
+        } else {
+            throw new BadRequestException("Invalid token type");
+        }
+
+        return $this->attemptJWTTokenValidation($token);
+    }
+
+    private function attemptJWTTokenValidation($jwt): object {
+
+        $jwtParts = explode('.', $jwt);
+        if (count($jwtParts) === 0) {
+            throw new UnauthorizedException("Invalid token");
+        }
+
+        $header = JWT::jsonDecode(JWT::urlsafeB64Decode($jwtParts[0]));
+        if (!isset($header->kid)) {
+            throw new UnauthorizedException("Invalid token: missing kid in header");
+        }
+
+        $keyPair = $this->locator->getJWKSDAO()->getRSAKeyPairById($header->kid);
+        if ($keyPair === null) {
+            throw new UnauthorizedException("Invalid kid: no key found");
+        }
+
+        try {
+            return JWT::decode($jwt, $keyPair->publicKey, [$keyPair->alg]);
+        } catch (\Firebase\JWT\ExpiredException $ex) {
+            throw new UnauthorizedException("Access token is expired");
+        }
+    }
+
+    public function checkScope(object $tokenData, string $desiredScope): void {
+
+        if (!(isset($tokenData->scope))) {
+            throw new UnauthorizedException("Missing 'scope' claim in access token");
+        }
+
+        $scopes = explode(' ', $tokenData->scope);
+
+        foreach ($scopes as $scope) {
+            if ($scope === $desiredScope) {
+                return;
+            }
+        }
+
+        throw new UnauthorizedException("Scope '$desiredScope' is required for performing this action");
+    }
+
+}
diff --git a/classes/User.php b/classes/User.php
deleted file mode 100644
index 21967ff717e1ab17a1dccb49f73912a5c3072d5b..0000000000000000000000000000000000000000
--- a/classes/User.php
+++ /dev/null
@@ -1,55 +0,0 @@
-<?php
-
-/* ----------------------------------------------------------------------------
- *               INAF - National Institute for Astrophysics
- *               IRA  - Radioastronomical Institute - Bologna
- *               OATS - Astronomical Observatory - Trieste
- * ----------------------------------------------------------------------------
- *
- * Copyright (C) 2016 Istituto Nazionale di Astrofisica
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License Version 3 as published by the
- * Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
- * details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program; if not, write to the Free Software Foundation, Inc., 51
- * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-namespace RAP;
-
-/**
- * Data model for the user. An user is a set of identities.
- */
-class User {
-
-    // User ID
-    public $id;
-    // List of identities
-    public $identities;
-
-    public function __construct() {
-        $this->identities = [];
-    }
-
-    public function addIdentity(Identity $identity) {
-        array_push($this->identities, $identity);
-    }
-
-    public function getPrimaryEmail() {
-        foreach ($this->identities as $identity) {
-            if ($identity->primary) {
-                return $identity->email;
-            }
-        }
-        // A primary identity MUST be defined
-        throw new \Exception("No primary identity defined for user " . $this->id);
-    }
-
-}
diff --git a/classes/UserHandler.php b/classes/UserHandler.php
index 4614a8f17b5004b7552c16e2a60eda7d56f8a76b..f744588c663e72c52140bd92468c62f2036ff268 100644
--- a/classes/UserHandler.php
+++ b/classes/UserHandler.php
@@ -29,12 +29,12 @@ namespace RAP;
  */
 class UserHandler {
 
-    private $dao;
-    private $grouperConfig;
+    private $locator;
+    private $userDAO;
 
-    public function __construct(DAO $dao, $grouperConfig) {
-        $this->dao = $dao;
-        $this->grouperConfig = $grouperConfig;
+    public function __construct(Locator $locator) {
+        $this->locator = $locator;
+        $this->userDAO = $locator->getUserDAO();
     }
 
     /**
@@ -49,59 +49,41 @@ class UserHandler {
         // If new user
         if ($user->id === null) {
             $primarySpecified = false;
-            $user->id = $this->dao->createUser();
+            $user->id = $this->userDAO->createUser();
         }
 
         foreach ($user->identities as $identity) {
             if ($identity->id === null) {
-                $identity->id = $this->dao->insertIdentity($identity, $user->id);
+                $identity->id = $this->userDAO->insertIdentity($identity, $user->id);
                 if (!$primarySpecified) {
-                    $this->dao->setPrimaryIdentity($user->id, $identity->id);
+                    $this->userDAO->setPrimaryIdentity($user->id, $identity->id);
                     $identity->primary = true;
                 }
             }
         }
     }
 
-    public function findUserByIdentity($type, $identifier) {
+    public function joinUsers(User $user1, User $user2): User {
 
-        return $this->dao->findUserByIdentity($type, $identifier);
-    }
-
-    /**
-     * Build an URL for the web service endpoint that needs to be called in order
-     * to move groups from one user to the other during a join operation.
-     * @return string grouper URL for the PrepareToJoinServlet
-     */
-    private function getJoinURL() {
-        $joinURL = $this->grouperConfig['wsURL'];
-
-        if (substr($joinURL, -1) !== '/') {
-            $joinURL .= '/';
-        }
-        $joinURL .= 'ia2join';
-
-        return $joinURL;
-    }
-
-    public function joinUsers($userId1, $userId2) {
+        $userId1 = $user1->id;
+        $userId2 = $user2->id;
 
         // Call Grouper for moving groups and privileges from one user to the other
-        if ($this->grouperConfig !== null) {
+        if (isset($this->locator->config->gms)) {
 
             //create cURL connection
-            $conn = curl_init($this->getJoinURL());
+            $conn = curl_init($this->locator->config->gms->joinEndpoint);
 
             //set options
             curl_setopt($conn, CURLOPT_CONNECTTIMEOUT, 30);
             curl_setopt($conn, CURLOPT_RETURNTRANSFER, true);
             curl_setopt($conn, CURLOPT_SSL_VERIFYPEER, true);
             curl_setopt($conn, CURLOPT_FOLLOWLOCATION, 1);
-            curl_setopt($conn, CURLOPT_USERPWD, $this->grouperConfig['user'] . ":" . $this->grouperConfig['password']);
+            curl_setopt($conn, CURLOPT_HTTPHEADER, ['Authorization: Bearer '
+                . $this->getJoinAccessToken($userId1, $userId2)]);
 
             //set data to be posted
             curl_setopt($conn, CURLOPT_POST, 1);
-            curl_setopt($conn, CURLOPT_POSTFIELDS, "subject1Id=RAP:$userId1&subject2Id=RAP:$userId2");
 
             //perform the request
             $response = curl_exec($conn);
@@ -112,13 +94,38 @@ class UserHandler {
             } else {
                 //show information regarding the error
                 curl_close($conn);
+                error_log($response);
                 http_response_code(500);
-                die('Error: Grouper response code: ' . $info['http_code']);
+                die('Error: GMS response code: ' . $info['http_code'] . "\n");
             }
         }
 
         // Call DAO for performing join operation into the RAP database.
-        $this->dao->joinUsers($userId1, $userId2);
+        $this->userDAO->joinUsers($userId1, $userId2);
+
+        foreach ($user2->identities as $identity) {
+            $identity->primary = false;
+            $user1->addIdentity($identity);
+        }
+
+        // merged user
+        return $user1;
+    }
+
+    private function getJoinAccessToken(int $userId1, int $userId2): string {
+
+        $gmsId = $this->locator->config->gms->id;
+
+        $accessToken = new AccessTokenData();
+        $accessToken->clientId = $gmsId;
+        $accessToken->userId = $userId1;
+        // shorter expiration
+        $accessToken->expirationTime = $accessToken->creationTime + 100;
+        $accessToken->scope = ['openid'];
+
+        return $this->locator->getTokenBuilder()->getAccessToken($accessToken, function(& $jwt) use($userId2) {
+                    $jwt['alt_sub'] = strval($userId2);
+                });
     }
 
 }
diff --git a/classes/UserSearchResult.php b/classes/UserSearchResult.php
deleted file mode 100644
index c1d49d90c8284a12229685fddb6ba6e704b10f86..0000000000000000000000000000000000000000
--- a/classes/UserSearchResult.php
+++ /dev/null
@@ -1,88 +0,0 @@
-<?php
-
-/* ----------------------------------------------------------------------------
- *               INAF - National Institute for Astrophysics
- *               IRA  - Radioastronomical Institute - Bologna
- *               OATS - Astronomical Observatory - Trieste
- * ----------------------------------------------------------------------------
- *
- * Copyright (C) 2016 Istituto Nazionale di Astrofisica
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License Version 3 as published by the
- * Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
- * details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program; if not, write to the Free Software Foundation, Inc., 51
- * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-
-namespace RAP;
-
-/**
- * Data model representing an item of the result of an user search. This is used
- * in order to display the dropdown menu for user selection in the join modal
- * dialog avoiding exposing the user identifiers in the AJAX calls.
- * This data is stored into a list inside the SessionData object. The user will
- * select one of the item by index and not by the user ID.
- */
-class UserSearchResult {
-
-    // The user object is wrapped by this class and hidden to AJAX.
-    private $user;
-    // Only this text is returned to the AJAX call.
-    // See gui-backend.php (/user?search endpoint)
-    public $userDisplayText;
-
-    public static function buildFromUser(User $user) {
-        $usr = new UserSearchResult();
-        $usr->user = $user;
-
-        $nameAndSurname = null;
-        $email = null;
-        $identityTypes = [];
-        foreach ($user->identities as $identity) {
-            array_push($identityTypes, $identity->getUIType());
-            if ($nameAndSurname === null && $identity->name !== null && $identity->surname !== null) {
-                $nameAndSurname = $identity->name . ' ' . $identity->surname;
-            }
-            if ($email === null) {
-                $email = $identity->email;
-            }
-        }
-
-        // Building display text string        
-        $displayText = "";
-
-        if ($nameAndSurname !== null) {
-            $displayText .= $nameAndSurname;
-        } else {
-            $displayText .= $email;
-        }
-
-        $displayText .= ' (';
-        $firstIdentity = true;
-        foreach ($identityTypes as $type) {
-            if (!$firstIdentity) {
-                $displayText .= '+';
-            }
-            $displayText .= $type;
-            $firstIdentity = false;
-        }
-        $displayText .= ')';
-
-        $usr->userDisplayText = $displayText;
-
-        return $usr;
-    }
-
-    public function getUser() {
-        return $this->user;
-    }
-
-}
diff --git a/classes/X509Data.php b/classes/X509Data.php
index ddfb4028bd44088b4c043b8f4b8127bc3cfd2148..3b8a931378138c4863bca14fa28e617ea3257a76 100644
--- a/classes/X509Data.php
+++ b/classes/X509Data.php
@@ -203,9 +203,7 @@ class X509Data {
         return $parsedData;
     }
 
-    public function toUser() {
-
-        $user = new User();
+    public function toIdentity() {
 
         $identity = new Identity(Identity::X509);
         $identity->email = $this->email;
@@ -214,9 +212,7 @@ class X509Data {
         $identity->typedId = $this->serialNumber;
         $identity->institution = $this->institution;
 
-        $user->addIdentity($identity);
-
-        return $user;
+        return $identity;
     }
 
 }
diff --git a/classes/datalayer/AccessTokenDAO.php b/classes/datalayer/AccessTokenDAO.php
new file mode 100644
index 0000000000000000000000000000000000000000..a7d6090c50aa72d1f008ba3aa650fabddea73184
--- /dev/null
+++ b/classes/datalayer/AccessTokenDAO.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace RAP;
+
+interface AccessTokenDAO {
+
+    /**
+     * Store a new login token into the database.
+     * @param type $token login token
+     * @param type $userId
+     */
+    function createTokenData(AccessTokenData $tokenData): AccessTokenData;
+
+    function retrieveTokenDataFromCode(string $codeHash): ?AccessTokenData;
+
+    function deleteTokenData(string $codeHash): void;
+}
diff --git a/classes/datalayer/JWKSDAO.php b/classes/datalayer/JWKSDAO.php
new file mode 100644
index 0000000000000000000000000000000000000000..9cd83f49900a9cfccd8cbadc77caa4116da2e0f2
--- /dev/null
+++ b/classes/datalayer/JWKSDAO.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace RAP;
+
+interface JWKSDAO {
+
+    public function getRSAKeyPairs(): array;
+
+    public function getRSAKeyPairById(string $id): ?RSAKeyPair;
+
+    public function insertRSAKeyPair(RSAKeyPair $keyPair): RSAKeyPair;
+
+    public function getNewestKeyPair(): ?RSAKeyPair;
+}
diff --git a/classes/datalayer/OAuth2ClientDAO.php b/classes/datalayer/OAuth2ClientDAO.php
new file mode 100644
index 0000000000000000000000000000000000000000..ffa4520b21fb64a213166d854de9f104776e4ba1
--- /dev/null
+++ b/classes/datalayer/OAuth2ClientDAO.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace RAP;
+
+/**
+ * CRUD methods for OAuth2Clients (used by admin interface).
+ */
+interface OAuth2ClientDAO {
+
+    function getOAuth2Clients(): array;
+
+    function createOAuth2Client(OAuth2Client $client): OAuth2Client;
+
+    function updateOAuth2Client(OAuth2Client $client): OAuth2Client;
+
+    function deleteOAuth2Client($clientId);
+
+    /**
+     * Retrieve the client from the configured client id (the one associated to
+     * the secret, not the database id).
+     */
+    function getOAuth2ClientByClientId($clientId): ?OAuth2Client;
+}
diff --git a/classes/datalayer/RefreshTokenDAO.php b/classes/datalayer/RefreshTokenDAO.php
new file mode 100644
index 0000000000000000000000000000000000000000..8124e96618bf56bb041006813d99fba55dd4bf5f
--- /dev/null
+++ b/classes/datalayer/RefreshTokenDAO.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace RAP;
+
+interface RefreshTokenDAO {
+
+    function createRefreshTokenData(RefreshTokenData $refreshToken): RefreshTokenData;
+
+    function getRefreshTokenData(string $tokenHash): ?RefreshTokenData;
+
+    function deleteRefreshTokenData(string $tokenHash): void;
+}
diff --git a/classes/DAO.php b/classes/datalayer/UserDAO.php
similarity index 56%
rename from classes/DAO.php
rename to classes/datalayer/UserDAO.php
index c5d5039b41e0661053affd93f8f5f9e64ea9edd3..a91d6275c533887833f10825c1ecfc1c607f131e 100644
--- a/classes/DAO.php
+++ b/classes/datalayer/UserDAO.php
@@ -24,38 +24,7 @@
 
 namespace RAP;
 
-/**
- * Data Access Object interface for accessing the RAP database.
- * Current implementations: RAP\MySQLDAO
- */
-interface DAO {
-
-    /**
-     * @return type PDO object for accessing the database
-     */
-    function getDBHandler();
-
-    /**
-     * Store a new login token into the database.
-     * @param type $token login token
-     * @param type $userId
-     */
-    function createLoginToken($token, $userId);
-
-    /**
-     * Retrieve the user ID from the login token.
-     * @param type $token
-     * @return type user ID
-     */
-    function findLoginToken($token);
-
-    /**
-     * Delete a login token from the database. This happens when the caller
-     * application has received the token and used it for retrieving user
-     * information from the token using the RAP REST web service.
-     * @param type $token login token
-     */
-    function deleteLoginToken($token);
+interface UserDAO {
 
     /**
      * Create a new identity.
@@ -73,7 +42,7 @@ interface DAO {
     /**
      * @return RAP\User an user object, null if nothing was found.
      */
-    function findUserById($userId);
+    function findUserById(string $userId): ?User;
 
     function setPrimaryIdentity($userId, $identityId);
 
@@ -93,22 +62,11 @@ interface DAO {
     function searchUser($searchText);
 
     /**
-     * Store into the database information about a new join request.
-     * @param type $token join token
-     * @param type $applicantUserId the user asking for the join
-     * @param type $targetUserId the user target of the join
+     * Retrieve a list of all users having given identifiers.
+     * @param array $identifiers
+     * @return array
      */
-    function createJoinRequest($token, $applicantUserId, $targetUserId);
-
-    /**
-     * Retrieve join request information.
-     * @param type $token join token
-     * @return an array of 2 elements having the applicant user id at the first
-     * position and the target user id at the second position; null if nothing
-     * was found.
-     * @throws Exception if multiple requests has been found for the same token.
-     */
-    function findJoinRequest($token);
+    function getUsers(array $identifiers): array;
 
     /**
      * Perform a join request.
@@ -118,10 +76,5 @@ interface DAO {
      */
     function joinUsers($userId1, $userId2);
 
-    /**
-     * When a join action is performed the join request data (join token and user
-     * identifiers) needs to be removed from the database.
-     * @param type $token join token
-     */
-    function deleteJoinRequest($token);
+    function isAdmin($userId): bool;
 }
diff --git a/classes/datalayer/mysql/BaseMySQLDAO.php b/classes/datalayer/mysql/BaseMySQLDAO.php
new file mode 100644
index 0000000000000000000000000000000000000000..7798f38be74c4eddc0d24c7ea1075a6a16fe59bd
--- /dev/null
+++ b/classes/datalayer/mysql/BaseMySQLDAO.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace RAP;
+
+use PDO;
+
+abstract class BaseMySQLDAO {
+
+    private $locator;
+
+    public function __construct(Locator $locator) {
+        $this->locator = $locator;
+    }
+
+    /**
+     * @return type PDO object for accessing the database
+     */
+    public function getDBHandler(): PDO {
+        $config = $this->locator->config->databaseConfig;
+        $connectionString = "mysql:host=" . $config->hostname . ";port=" . $config->port . ";dbname=" . $config->dbname;
+        $dbh = new PDO($connectionString, $config->username, $config->password);
+        // For transaction errors (see https://stackoverflow.com/a/9659366/771431)
+        $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+        return $dbh;
+    }
+
+}
diff --git a/classes/datalayer/mysql/MySQLAccessTokenDAO.php b/classes/datalayer/mysql/MySQLAccessTokenDAO.php
new file mode 100644
index 0000000000000000000000000000000000000000..00b5954cb3602ed47bda60615f55160c58b7c437
--- /dev/null
+++ b/classes/datalayer/mysql/MySQLAccessTokenDAO.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace RAP;
+
+class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO {
+
+    public function __construct(Locator $locator) {
+        parent::__construct($locator);
+    }
+
+    public function createTokenData(AccessTokenData $tokenData): AccessTokenData {
+
+        $dbh = $this->getDBHandler();
+        $stmt = $dbh->prepare("INSERT INTO access_token (code_hash, user_id, redirect_uri, client_id, scope, creation_time, expiration_time)"
+                . " VALUES(:code_hash, :user_id, :redirect_uri, :client_id, :scope, :creation_time, :expiration_time)");
+
+        $scope = null;
+        if ($tokenData->scope !== null) {
+            $scope = join(' ', $tokenData->scope);
+        }
+
+        $params = array(
+            ':code_hash' => $tokenData->codeHash,
+            ':user_id' => $tokenData->userId,
+            ':redirect_uri' => $tokenData->redirectUri,
+            ':client_id' => $tokenData->clientId,
+            ':scope' => $scope,
+            ':creation_time' => $tokenData->creationTime,
+            ':expiration_time' => $tokenData->expirationTime
+        );
+
+        if ($stmt->execute($params)) {
+            return $tokenData;
+        } else {
+            error_log($stmt->errorInfo()[2]);
+            throw new \Exception("SQL error while storing user token");
+        }
+    }
+
+    public function retrieveTokenDataFromCode(string $codeHash): ?AccessTokenData {
+
+        $dbh = $this->getDBHandler();
+
+        // Access token can be retrieved from code in 1 minute from the creation
+        $stmt = $dbh->prepare("SELECT user_id, redirect_uri, client_id, creation_time, expiration_time, scope "
+                . " FROM access_token WHERE code_hash = :code_hash AND UNIX_TIMESTAMP() < (creation_time + 60)");
+        $stmt->bindParam(':code_hash', $codeHash);
+
+        $stmt->execute();
+
+        $row = $stmt->fetch();
+        if (!$row) {
+            return null;
+        }
+
+        return $this->getTokenDataFromRow($row);
+    }
+
+    private function getTokenDataFromRow(array $row): AccessTokenData {
+
+        $token = new AccessTokenData();
+        $token->userId = $row['user_id'];
+        $token->redirectUri = $row['redirect_uri'];
+        $token->clientId = $row['client_id'];
+        $token->creationTime = $row['creation_time'];
+        $token->expirationTime = $row['expiration_time'];
+
+        $scope = null;
+        if (isset($row['scope'])) {
+            $scope = $row['scope'];
+        }
+        if ($scope !== null && $scope !== '') {
+            $token->scope = explode(' ', $scope);
+        }
+
+        return $token;
+    }
+
+    function deleteTokenData(string $codeHash): void {
+
+        $dbh = $this->getDBHandler();
+
+        $stmt = $dbh->prepare("DELETE FROM access_token WHERE code_hash = :code_hash");
+        $stmt->bindParam(':code_hash', $codeHash);
+
+        $stmt->execute();
+    }
+
+}
diff --git a/classes/datalayer/mysql/MySQLJWKSDAO.php b/classes/datalayer/mysql/MySQLJWKSDAO.php
new file mode 100644
index 0000000000000000000000000000000000000000..8e7a83ad188340b257950396055f12ceee49ec17
--- /dev/null
+++ b/classes/datalayer/mysql/MySQLJWKSDAO.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace RAP;
+
+class MySQLJWKSDAO extends BaseMySQLDAO implements JWKSDAO {
+
+    public function __construct($config) {
+        parent::__construct($config);
+    }
+
+    public function insertRSAKeyPair(RSAKeyPair $keyPair): RSAKeyPair {
+
+        $dbh = $this->getDBHandler();
+
+        $query = "INSERT INTO rsa_keypairs(id, private_key, public_key, alg) VALUES (:id, :private_key, :public_key, :alg)";
+
+        $stmt = $dbh->prepare($query);
+        $stmt->bindParam(':id', $keyPair->keyId);
+        $stmt->bindParam(':private_key', $keyPair->privateKey);
+        $stmt->bindParam(':public_key', $keyPair->publicKey);
+        $stmt->bindParam(':alg', $keyPair->alg);
+
+        $stmt->execute();
+
+        return $keyPair;
+    }
+
+    public function getRSAKeyPairs(): array {
+
+        $dbh = $this->getDBHandler();
+
+        $query = "SELECT id, private_key, public_key, alg, creation_time FROM rsa_keypairs";
+
+        $stmt = $dbh->prepare($query);
+        $stmt->execute();
+
+        $keyPairs = [];
+        foreach ($stmt->fetchAll() as $row) {
+            $keyPair = $this->getRSAKeyPairFromResultRow($row);
+            array_push($keyPairs, $keyPair);
+        }
+
+        return $keyPairs;
+    }
+
+    public function getRSAKeyPairById(string $id): ?RSAKeyPair {
+
+        $dbh = $this->getDBHandler();
+
+        $query = "SELECT id, private_key, public_key, alg, creation_time FROM rsa_keypairs WHERE id = :id";
+
+        $stmt = $dbh->prepare($query);
+        $stmt->bindParam(':id', $id);
+        $stmt->execute();
+
+        foreach ($stmt->fetchAll() as $row) {
+            return $this->getRSAKeyPairFromResultRow($row);
+        }
+
+        return null;
+    }
+
+    public function getNewestKeyPair(): ?RSAKeyPair {
+        $dbh = $this->getDBHandler();
+
+        $query = "SELECT id, private_key, public_key, alg, creation_time FROM rsa_keypairs ORDER BY creation_time DESC LIMIT 1";
+
+        $stmt = $dbh->prepare($query);
+        $stmt->execute();
+
+        foreach ($stmt->fetchAll() as $row) {
+            return $this->getRSAKeyPairFromResultRow($row);
+        }
+
+        return null;
+    }
+
+    private function getRSAKeyPairFromResultRow(array $row): RSAKeyPair {
+        $keyPair = new RSAKeyPair();
+        $keyPair->keyId = $row['id'];
+        $keyPair->privateKey = $row['private_key'];
+        $keyPair->publicKey = $row['public_key'];
+        $keyPair->alg = $row['alg'];
+        $keyPair->creationTime = $row['creation_time'];
+        return $keyPair;
+    }
+
+}
diff --git a/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php b/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php
new file mode 100644
index 0000000000000000000000000000000000000000..7890e95c8ee1b5b48fa4814a1f03a81d81c7070a
--- /dev/null
+++ b/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php
@@ -0,0 +1,249 @@
+<?php
+
+namespace RAP;
+
+class MySQLOAuth2ClientDAO extends BaseMySQLDAO implements OAuth2ClientDAO {
+
+    public function __construct($config) {
+        parent::__construct($config);
+    }
+
+    public function getOAuth2Clients(): array {
+
+        $dbh = $this->getDBHandler();
+
+        $clientsMap = $this->getClientsMap($dbh);
+        $this->loadAuthenticationMethods($dbh, $clientsMap);
+        $this->loadScopeAudienceMapping($dbh, $clientsMap);
+
+        $clients = [];
+        foreach ($clientsMap as $id => $client) {
+            array_push($clients, $client);
+        }
+
+        return $clients;
+    }
+
+    private function getClientsMap(\PDO $dbh): array {
+
+        // Load clients info
+        $queryClient = "SELECT id, title, icon, client, secret, redirect_url, scope, home_page, show_in_home FROM oauth2_client";
+        $stmtClients = $dbh->prepare($queryClient);
+        $stmtClients->execute();
+
+        $clientsMap = [];
+
+        foreach ($stmtClients->fetchAll() as $row) {
+            $client = new OAuth2Client();
+            $client->id = $row['id'];
+            $client->title = $row['title'];
+            $client->icon = $row['icon'];
+            $client->client = $row['client'];
+            $client->secret = $row['secret'];
+            $client->redirectUrl = $row['redirect_url'];
+            $client->scope = $row['scope'];
+            $client->homePage = $row['home_page'];
+            $client->showInHome = boolval($row['show_in_home']);
+            $clientsMap[$client->id] = $client;
+        }
+
+        return $clientsMap;
+    }
+
+    private function loadAuthenticationMethods(\PDO $dbh, array $clientsMap): void {
+
+        $queryAuthNMethods = "SELECT client_id, auth_method FROM oauth2_client_auth_methods";
+
+        $stmtAuthNMethods = $dbh->prepare($queryAuthNMethods);
+        $stmtAuthNMethods->execute();
+
+        foreach ($stmtAuthNMethods->fetchAll() as $row) {
+            $id = $row['client_id'];
+            array_push($clientsMap[$id]->authMethods, $row['auth_method']);
+        }
+    }
+
+    private function loadScopeAudienceMapping(\PDO $dbh, array $clientsMap): void {
+
+        $query = "SELECT client_id, scope, audience FROM oauth2_client_scope_audience_mapping";
+
+        $stmt = $dbh->prepare($query);
+
+        foreach ($stmt->fetchAll() as $row) {
+            $id = $row['client_id'];
+
+            if (array_key_exists($id, $clientsMap)) {
+                $client = $clientsMap[$id];
+                $client->scopeAudienceMap[$row['scope']] = $row['audience'];
+            }
+
+            array_push($clientsMap[$id]->authMethods, $row['auth_method']);
+        }
+    }
+
+    function createOAuth2Client(OAuth2Client $client): OAuth2Client {
+        $dbh = $this->getDBHandler();
+
+        try {
+            $dbh->beginTransaction();
+
+            $stmt = $dbh->prepare("INSERT INTO `oauth2_client`(`title`, `icon`, `client`, `secret`, `redirect_url`, `scope`, home_page, show_in_home)"
+                    . " VALUES(:title, :icon, :client, :secret, :redirect_url, :scope, :home_page, :show_in_home)");
+
+            $stmt->bindParam(':title', $client->title);
+            $stmt->bindParam(':icon', $client->icon);
+            $stmt->bindParam(':client', $client->client);
+            $stmt->bindParam(':secret', $client->secret);
+            $stmt->bindParam(':redirect_url', $client->redirectUrl);
+            $stmt->bindParam(':scope', $client->scope);
+            $stmt->bindParam(':home_page', $client->homePage);
+            $stmt->bindParam(':show_in_home', $client->showInHome, \PDO::PARAM_INT);
+
+            $stmt->execute();
+
+            $client->id = $dbh->lastInsertId();
+
+            foreach ($client->authMethods as $method) {
+                $stmt = $dbh->prepare("INSERT INTO `oauth2_client_auth_methods`(`client_id`, `auth_method`)"
+                        . " VALUES(:client_id, :auth_method)");
+
+                $stmt->bindParam(':client_id', $client->id);
+                $stmt->bindParam(':auth_method', $method);
+
+                $stmt->execute();
+            }
+
+            $dbh->commit();
+        } catch (Exception $ex) {
+            $dbh->rollBack();
+            throw $ex;
+        }
+
+        return $client;
+    }
+
+    function updateOAuth2Client(OAuth2Client $client): OAuth2Client {
+        $dbh = $this->getDBHandler();
+
+        try {
+            $dbh->beginTransaction();
+
+            $stmt = $dbh->prepare("UPDATE `oauth2_client` SET `title` = :title, `icon` = :icon, "
+                    . " `client` = :client, `secret` = :secret, `redirect_url` = :redirect_url, `scope` = :scope, "
+                    . " `home_page` = :home_page, `show_in_home` = :show_in_home"
+                    . " WHERE id = :id");
+
+            $stmt->bindParam(':title', $client->title);
+            $stmt->bindParam(':icon', $client->icon);
+            $stmt->bindParam(':client', $client->client);
+            $stmt->bindParam(':secret', $client->secret);
+            $stmt->bindParam(':redirect_url', $client->redirectUrl);
+            $stmt->bindParam(':scope', $client->scope);
+            $stmt->bindParam(':home_page', $client->homePage);
+            $stmt->bindParam(':show_in_home', $client->showInHome, \PDO::PARAM_INT);
+            $stmt->bindParam(':id', $client->id);
+
+            $stmt->execute();
+
+            // Delete old authentication methods
+            $stmt = $dbh->prepare("DELETE FROM oauth2_client_auth_methods WHERE client_id = :id");
+            $stmt->bindParam(':id', $client->id);
+
+            $stmt->execute();
+
+            // Re-add authentication methods
+            foreach ($client->authMethods as $method) {
+                $stmt = $dbh->prepare("INSERT INTO `oauth2_client_auth_methods`(`client_id`, `auth_method`)"
+                        . " VALUES(:client_id, :auth_method)");
+
+                $stmt->bindParam(':client_id', $client->id);
+                $stmt->bindParam(':auth_method', $method);
+
+                $stmt->execute();
+            }
+
+            $dbh->commit();
+        } catch (Exception $ex) {
+            $dbh->rollBack();
+            throw $ex;
+        }
+
+        return $client;
+    }
+
+    function deleteOAuth2Client($clientId) {
+        $dbh = $this->getDBHandler();
+        try {
+            $dbh->beginTransaction();
+
+            $stmt = $dbh->prepare("DELETE FROM `oauth2_client_auth_methods` WHERE client_id = :id");
+            $stmt->bindParam(':id', $clientId);
+            $stmt->execute();
+
+            $stmt = $dbh->prepare("DELETE FROM `oauth2_client` WHERE id = :id");
+            $stmt->bindParam(':id', $clientId);
+            $stmt->execute();
+
+            $dbh->commit();
+        } catch (Exception $ex) {
+            $dbh->rollBack();
+            throw $ex;
+        }
+    }
+
+    function getOAuth2ClientByClientId($clientId): ?OAuth2Client {
+        $dbh = $this->getDBHandler();
+
+        // Load clients info
+        $queryClient = "SELECT id, title, icon, client, secret, redirect_url, scope, home_page, show_in_home FROM oauth2_client WHERE client = :client";
+        $stmtClient = $dbh->prepare($queryClient);
+        $stmtClient->bindParam(':client', $clientId);
+        $stmtClient->execute();
+
+        $result = $stmtClient->fetchAll();
+
+        if (count($result) === 0) {
+            return null;
+        }
+        if (count($result) > 1) {
+            throw new \Exception("Found multiple clients associated to the same client id!");
+        }
+
+        $row = $result[0];
+
+        $client = new OAuth2Client();
+        $client->id = $row['id'];
+        $client->title = $row['title'];
+        $client->icon = $row['icon'];
+        $client->client = $row['client'];
+        $client->secret = $row['secret'];
+        $client->redirectUrl = $row['redirect_url'];
+        $client->scope = $row['scope'];
+        $client->homePage = $row['home_page'];
+        $client->showInHome = $row['show_in_home'];
+
+        // Load authentication methods info
+        $queryAuthNMethods = "SELECT auth_method FROM oauth2_client_auth_methods WHERE client_id = :id";
+
+        $stmtAuthNMethods = $dbh->prepare($queryAuthNMethods);
+        $stmtAuthNMethods->bindParam(':id', $client->id);
+        $stmtAuthNMethods->execute();
+
+        foreach ($stmtAuthNMethods->fetchAll() as $row) {
+            array_push($client->authMethods, $row['auth_method']);
+        }
+
+        // Load scope-audience mapping
+        $queryAudienceMapping = "SELECT scope, audience FROM oauth2_client_scope_audience_mapping WHERE client_id = :id";
+        $stmtAudienceMapping = $dbh->prepare($queryAudienceMapping);
+        $stmtAudienceMapping->bindParam(':id', $client->id);
+        $stmtAudienceMapping->execute();
+
+        foreach ($stmtAudienceMapping->fetchAll() as $row) {
+            $client->scopeAudienceMap[$row['scope']] = $row['audience'];
+        }
+
+        return $client;
+    }
+
+}
diff --git a/classes/datalayer/mysql/MySQLRefreshTokenDAO.php b/classes/datalayer/mysql/MySQLRefreshTokenDAO.php
new file mode 100644
index 0000000000000000000000000000000000000000..28b5c2804a9b7cbdf4d092ed38d032774db5af39
--- /dev/null
+++ b/classes/datalayer/mysql/MySQLRefreshTokenDAO.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace RAP;
+
+class MySQLRefreshTokenDAO extends BaseMySQLDAO implements RefreshTokenDAO {
+
+    public function __construct(Locator $locator) {
+        parent::__construct($locator);
+    }
+
+    function createRefreshTokenData(RefreshTokenData $refreshTokenData): RefreshTokenData {
+
+        $dbh = $this->getDBHandler();
+        $stmt = $dbh->prepare("INSERT INTO refresh_token (token_hash, user_id, client_id, scope, creation_time, expiration_time)"
+                . " VALUES(:token_hash, :user_id, :client_id, :scope, :creation_time, :expiration_time)");
+
+        $scope = null;
+        if ($refreshTokenData->scope !== null) {
+            $scope = join(' ', $refreshTokenData->scope);
+        }
+
+        $params = array(
+            ':token_hash' => $refreshTokenData->tokenHash,
+            ':user_id' => $refreshTokenData->userId,
+            ':client_id' => $refreshTokenData->clientId,
+            ':scope' => $scope,
+            ':creation_time' => $refreshTokenData->creationTime,
+            ':expiration_time' => $refreshTokenData->expirationTime
+        );
+
+        if ($stmt->execute($params)) {
+            return $refreshTokenData;
+        } else {
+            error_log($stmt->errorInfo()[2]);
+            throw new \Exception("SQL error while storing user token");
+        }
+    }
+
+    function getRefreshTokenData(string $tokenHash): ?RefreshTokenData {
+
+        $dbh = $this->getDBHandler();
+
+        $stmt = $dbh->prepare("SELECT user_id, client_id, creation_time, expiration_time, scope "
+                . " FROM refresh_token WHERE token_hash = :token_hash");
+
+        $stmt->bindParam(':token_hash', $tokenHash);
+
+        $stmt->execute();
+
+        $row = $stmt->fetch();
+        if (!$row) {
+            return null;
+        }
+
+        $token = new RefreshTokenData();
+        $token->tokenHash = $tokenHash;
+        $token->userId = $row['user_id'];
+        $token->clientId = $row['client_id'];
+        $token->creationTime = $row['creation_time'];
+        $token->expirationTime = $row['expiration_time'];
+
+        $scope = null;
+        if (isset($row['scope'])) {
+            $scope = $row['scope'];
+        }
+        if ($scope !== null && $scope !== '') {
+            $token->scope = explode(' ', $scope);
+        }
+
+        return $token;
+    }
+
+    function deleteRefreshTokenData(string $tokenHash): void {
+
+        $dbh = $this->getDBHandler();
+
+        $stmt = $dbh->prepare("DELETE FROM refresh_token WHERE token_hash = :token_hash");
+        $stmt->bindParam(':token_hash', $tokenHash);
+        $stmt->execute();
+    }
+
+}
diff --git a/classes/MySQLDAO.php b/classes/datalayer/mysql/MySQLUserDAO.php
similarity index 66%
rename from classes/MySQLDAO.php
rename to classes/datalayer/mysql/MySQLUserDAO.php
index f81186d7ddb042f8046157c22bd6209d90282f7b..14f8c0d283e118c7046821c260c7375f2a8fcbdf 100644
--- a/classes/MySQLDAO.php
+++ b/classes/datalayer/mysql/MySQLUserDAO.php
@@ -24,68 +24,13 @@
 
 namespace RAP;
 
-use PDO;
-
 /**
  * MySQL implementation of the DAO interface. See comments on the DAO interface.
  */
-class MySQLDAO implements DAO {
-
-    private $config;
-
-    public function __construct($config) {
-        $this->config = $config;
-    }
-
-    public function getDBHandler() {
-        $connectionString = "mysql:host=" . $this->config['hostname'] . ";dbname=" . $this->config['dbname'];
-        $dbh = new PDO($connectionString, $this->config['username'], $this->config['password']);
-        // For transaction errors (see https://stackoverflow.com/a/9659366/771431)
-        $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
-        return $dbh;
-    }
-
-    public function createLoginToken($token, $userId) {
-
-        $dbh = $this->getDBHandler();
-        $stmt = $dbh->prepare("INSERT INTO login_token (token, user_id) VALUES(:token, :user_id)");
-
-        $params = array(
-            ':token' => $token,
-            ':user_id' => $userId
-        );
-
-        if ($stmt->execute($params)) {
-            return $token;
-        } else {
-            error_log($stmt->errorInfo()[2]);
-            throw new \Exception("SQL error while storing user token");
-        }
-    }
-
-    public function findLoginToken($token) {
-
-        $dbh = $this->getDBHandler();
-
-        $stmt = $dbh->prepare("SELECT user_id FROM login_token WHERE token = :token AND CURRENT_TIMESTAMP < TIMESTAMPADD(MINUTE,1,creation_time)");
-        $stmt->bindParam(':token', $token);
-
-        $stmt->execute();
-
-        foreach ($stmt->fetchAll() as $row) {
-            return $row['user_id'];
-        }
-
-        return null;
-    }
-
-    public function deleteLoginToken($token) {
-
-        $dbh = $this->getDBHandler();
+class MySQLUserDAO extends BaseMySQLDAO implements UserDAO {
 
-        $stmt = $dbh->prepare("DELETE FROM login_token WHERE token = :token");
-        $stmt->bindParam(':token', $token);
-        $stmt->execute();
+    public function __construct(Locator $locator) {
+        parent::__construct($locator);
     }
 
     public function insertIdentity(Identity $identity, $userId) {
@@ -124,7 +69,7 @@ class MySQLDAO implements DAO {
         $identity = new Identity($row['type']);
 
         $identity->id = $row['id'];
-        $identity->primary = $row['primary'];
+        $identity->primary = boolval($row['primary']);
         $identity->typedId = $row['typed_id'];
         $identity->email = $row['email'];
         $identity->name = $row['name'];
@@ -135,7 +80,7 @@ class MySQLDAO implements DAO {
         return $identity;
     }
 
-    public function findUserById($userId) {
+    public function findUserById(string $userId): ?User {
 
         if (!filter_var($userId, FILTER_VALIDATE_INT)) {
             return null;
@@ -229,6 +174,47 @@ class MySQLDAO implements DAO {
         $stmt->bindParam(':surname', $searchParam);
         $stmt->bindParam(':namesurname', $searchParam);
 
+        return $this->getUsersListFromStatement($stmt);
+    }
+
+    public function getUsers(array $identifiers): array {
+
+        if (count($identifiers) === 0) {
+            return [];
+        }
+
+        $dbh = $this->getDBHandler();
+
+        $query = "SELECT `user_id`, (u.`primary_identity` = i.`id`) AS `primary`,"
+                . " i.`id`, `type`, `typed_id`, `email`, `name`, `surname`, `institution`, `eppn`"
+                . " FROM identity i"
+                . " JOIN `user` u on u.id = i.user_id"
+                . " WHERE i.user_id IN (";
+
+        $first = true;
+        foreach ($identifiers as $id) {
+            if (!$first) {
+                $query .= ',';
+            }
+            $query .= ':id_' . $id;
+            if ($first) {
+                $first = !$first;
+            }
+        }
+
+        $query .= ')';
+
+        $stmt = $dbh->prepare($query);
+
+        foreach ($identifiers as &$id) {
+            $stmt->bindParam(':id_' . $id, $id);
+        }
+
+        return $this->getUsersListFromStatement($stmt);
+    }
+
+    private function getUsersListFromStatement(\PDOStatement $stmt): array {
+
         $stmt->execute();
 
         $userMap = array();
@@ -256,44 +242,6 @@ class MySQLDAO implements DAO {
         return $users;
     }
 
-    public function createJoinRequest($token, $applicantUserId, $targetUserId) {
-
-        if ($applicantUserId === $targetUserId) {
-            throw new \Exception("Invalid target user id");
-        }
-
-        $dbh = $this->getDBHandler();
-
-        $stmt = $dbh->prepare("INSERT INTO `join_request`(`token`, `applicant_user_id`, `target_user_id`)"
-                . " VALUES(:token, :applicant_user_id, :target_user_id)");
-
-        $stmt->bindParam(':token', $token);
-        $stmt->bindParam(':applicant_user_id', $applicantUserId);
-        $stmt->bindParam(':target_user_id', $targetUserId);
-
-        $stmt->execute();
-    }
-
-    public function findJoinRequest($token) {
-        $dbh = $this->getDBHandler();
-
-        $stmt = $dbh->prepare("SELECT `applicant_user_id`, `target_user_id` FROM `join_request` WHERE `token` = :token");
-        $stmt->bindParam(':token', $token);
-        $stmt->execute();
-
-        $result = $stmt->fetchAll();
-
-        switch (count($result)) {
-            case 0:
-                return null;
-            case 1:
-                $row = $result[0];
-                return [$row['applicant_user_id'], $row['target_user_id']];
-            default:
-                throw new Exception("Found multiple join request with the same token");
-        }
-    }
-
     public function joinUsers($userId1, $userId2) {
         $dbh = $this->getDBHandler();
 
@@ -306,16 +254,10 @@ class MySQLDAO implements DAO {
             $stmt1->bindParam(':id2', $userId2);
             $stmt1->execute();
 
-            // Deleting user2 join requests
-            $stmt3 = $dbh->prepare("DELETE FROM `join_request` WHERE `target_user_id` = :tid2 OR `applicant_user_id` = :aid2");
-            $stmt3->bindParam(':tid2', $userId2);
-            $stmt3->bindParam(':aid2', $userId2);
-            $stmt3->execute();
-
             // Deleting user2
-            $stmt4 = $dbh->prepare("DELETE FROM `user` WHERE `id` = :id2");
-            $stmt4->bindParam(':id2', $userId2);
-            $stmt4->execute();
+            $stmt2 = $dbh->prepare("DELETE FROM `user` WHERE `id` = :id2");
+            $stmt2->bindParam(':id2', $userId2);
+            $stmt2->execute();
 
             $dbh->commit();
         } catch (Exception $ex) {
@@ -324,12 +266,19 @@ class MySQLDAO implements DAO {
         }
     }
 
-    public function deleteJoinRequest($token) {
+    function isAdmin($userId): bool {
+
         $dbh = $this->getDBHandler();
 
-        $stmt = $dbh->prepare("DELETE FROM `join_request` WHERE `token` = :token");
-        $stmt->bindParam(':token', $token);
+        $query = "SELECT user_id FROM rap_permissions WHERE permission = 'ADMIN' AND user_id = :userId";
+
+        $stmt = $dbh->prepare($query);
+        $stmt->bindParam(':userId', $userId);
         $stmt->execute();
+
+        $result = $stmt->fetchAll();
+
+        return count($result) === 1;
     }
 
 }
diff --git a/classes/exceptions/BadRequestException.php b/classes/exceptions/BadRequestException.php
new file mode 100644
index 0000000000000000000000000000000000000000..fb7576e6f534b692466d6c872a11dec8a18bb7bc
--- /dev/null
+++ b/classes/exceptions/BadRequestException.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace RAP;
+
+class BadRequestException extends \Exception {
+
+    public $message;
+
+    public function __construct($message) {
+        $this->message = $message;
+    }
+
+}
diff --git a/classes/exceptions/UnauthorizedException.php b/classes/exceptions/UnauthorizedException.php
new file mode 100644
index 0000000000000000000000000000000000000000..27cd752d71ea6b37a79495071ef116dffd4cbf01
--- /dev/null
+++ b/classes/exceptions/UnauthorizedException.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace RAP;
+
+class UnauthorizedException extends \Exception {
+
+    public $message;
+
+    public function __construct($message) {
+        $this->message = $message;
+    }
+
+}
diff --git a/classes/login/FacebookLogin.php b/classes/login/FacebookLogin.php
new file mode 100644
index 0000000000000000000000000000000000000000..8e5dc8438f3d7bda5c476306342fc9cdf932102f
--- /dev/null
+++ b/classes/login/FacebookLogin.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace RAP;
+
+class FacebookLogin extends LoginHandler {
+
+    public function __construct(Locator $locator) {
+        parent::__construct($locator, Identity::FACEBOOK);
+    }
+
+    public function login(): string {
+
+        // Retrieve Facebook configuration
+        $Facebook = $this->locator->config->authenticationMethods->Facebook;
+
+        $fb = new \Facebook\Facebook([
+            'app_id' => $Facebook->id,
+            'app_secret' => $Facebook->secret,
+            'default_graph_version' => $Facebook->version,
+        ]);
+
+        $helper = $fb->getRedirectLoginHelper();
+
+        $permissions = ['email']; // Optional permissions: we need user email
+
+        $loginUrl = $helper->getLoginUrl($this->locator->getBasePath() . $Facebook->callback, $permissions);
+
+        return $loginUrl;
+    }
+
+    public function retrieveToken(): string {
+        // Retrieve Facebook configuration
+        $Facebook = $this->locator->config->authenticationMethods->Facebook;
+
+        $fb = new \Facebook\Facebook([
+            'app_id' => $Facebook->id,
+            'app_secret' => $Facebook->secret,
+            'default_graph_version' => $Facebook->version,
+        ]);
+
+        $helper = $fb->getRedirectLoginHelper();
+        if (isset($_GET['state'])) {
+            $helper->getPersistentDataHandler()->set('state', $_GET['state']);
+        }
+
+        try {
+            // obtaining current URL without query string
+            $url = "https://$_SERVER[HTTP_HOST]" . strtok($_SERVER["REQUEST_URI"], '?');
+            $accessToken = $helper->getAccessToken($url);
+        } catch (Facebook\Exceptions\FacebookResponseException $e) {
+            // When Graph returns an error
+            http_response_code(500);
+            die('Graph returned an error: ' . $e->getMessage());
+        } catch (Facebook\Exceptions\FacebookSDKException $e) {
+            // When validation fails or other local issues
+            http_response_code(500);
+            die('Facebook SDK returned an error: ' . $e->getMessage());
+        }
+        if (!isset($accessToken)) {
+            if ($helper->getError()) {
+                $errorMessage = "Error: " . $helper->getError() . "<br>";
+                $errorMessage = $errorMessage . "Error Code: " . $helper->getErrorCode() . "<br>";
+                $errorMessage = $errorMessage . "Error Reason: " . $helper->getErrorReason() . "<br>";
+                $errorMessage = $errorMessage . "Error Description: " . $helper->getErrorDescription();
+            } else {
+                $errorMessage = "Bad request";
+            }
+
+            http_response_code(500);
+            die($errorMessage);
+        }
+
+        try {
+            // Returns a `Facebook\FacebookResponse` object
+            $response = $fb->get('/me?fields=id,first_name,last_name,email', $accessToken);
+        } catch (Facebook\Exceptions\FacebookResponseException $e) {
+            echo 'Graph returned an error: ' . $e->getMessage();
+            exit;
+        } catch (Facebook\Exceptions\FacebookSDKException $e) {
+            echo 'Facebook SDK returned an error: ' . $e->getMessage();
+            exit;
+        }
+
+        $_SESSION['fb_access_token'] = (string) $accessToken;
+
+        $fbUser = $response->getGraphUser();
+
+        $typedId = $fbUser["id"];
+
+        return $this->onIdentityDataReceived($typedId, function($identity) use($fbUser) {
+                    $identity->email = $fbUser["email"];
+                    $identity->name = $fbUser["first_name"];
+                    $identity->surname = $fbUser["last_name"];
+                });
+    }
+
+}
diff --git a/classes/login/GoogleLogin.php b/classes/login/GoogleLogin.php
new file mode 100644
index 0000000000000000000000000000000000000000..82079aae58bb820a21216e121b8cff8007ed5a89
--- /dev/null
+++ b/classes/login/GoogleLogin.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace RAP;
+
+class GoogleLogin extends LoginHandler {
+
+    public function __construct(Locator $locator) {
+        parent::__construct($locator, Identity::GOOGLE);
+    }
+
+    public function login() {
+        // Retrieve Google configuration
+
+        $Google = $this->locator->config->authenticationMethods->Google;
+
+        $client = new \Google_Client(array(
+            'client_id' => $Google->id,
+            'client_secret' => $Google->secret,
+            'redirect_uri' => $this->locator->getBasePath() . $Google->callback,
+        ));
+
+        // Ask permission to obtain user email and profile information
+        $client->setScopes(array(\Google_Service_People::USERINFO_EMAIL, \Google_Service_People::USERINFO_PROFILE));
+
+        if (isset($_REQUEST['logout'])) {
+            // Reset the access token stored into the session
+            unset($_SESSION['access_token']);
+        }
+
+        if (isset($_GET['code'])) {
+            // An access token has been returned from the auth URL.
+            $client->authenticate($_GET['code']);
+            $_SESSION['access_token'] = $client->getAccessToken();
+        }
+
+        if ($client->getAccessToken()) {
+
+            // Query web service for retrieving user information
+            $service = new \Google_Service_People($client);
+
+            try {
+                $res = $service->people->get('people/me', array('requestMask.includeField' => 'person.names,person.email_addresses'));
+            } catch (Google_Service_Exception $e) {
+                echo '<p>' . json_encode($e->getErrors()) . '</p>';
+                $thisPage = $PROTOCOL . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'];
+                echo '<p><a href="' . $thisPage . '?logout">Click here to unset the access token</a></p>';
+            }
+
+            $name = $res->getNames()[0]->getGivenName();
+            $surname = $res->getNames()[0]->getFamilyName();
+
+            $emailAddresses = [];
+            foreach ($res->getEmailAddresses() as $addr) {
+                array_push($emailAddresses, $addr->value);
+            }
+
+            $typedId = explode('/', $res->getResourceName())[1];
+
+            return $this->onIdentityDataReceived($typedId, function($identity) use($emailAddresses, $name, $surname) {
+                        $identity->email = $emailAddresses[0];
+                        $identity->name = $name;
+                        $identity->surname = $surname;
+                    });
+        } else {
+            // Redirect to Google authorization URL for obtaining an access token
+            $authUrl = $client->createAuthUrl();
+            header('Location: ' . $authUrl);
+            die();
+        }
+
+        return null;
+    }
+
+}
diff --git a/classes/login/LinkedInLogin.php b/classes/login/LinkedInLogin.php
new file mode 100644
index 0000000000000000000000000000000000000000..57c4f3b33ec6d73907b92fc588b27595c7ca2330
--- /dev/null
+++ b/classes/login/LinkedInLogin.php
@@ -0,0 +1,148 @@
+<?php
+
+namespace RAP;
+
+class LinkedInLogin extends LoginHandler {
+
+    public function __construct(Locator $locator) {
+        parent::__construct($locator, Identity::LINKEDIN);
+    }
+
+    public function login(): string {
+        // Retrieve LinkedIn configuration
+        $LinkedIn = $this->locator->config->authenticationMethods->LinkedIn;
+
+        $url = "https://www.linkedin.com/oauth/v2/authorization?response_type=code";
+        $url .= "&client_id=" . $LinkedIn->id;
+        $url .= "&redirect_uri=" . $this->locator->getBasePath() . $LinkedIn->callback;
+        $url .= "&state=" . bin2hex(random_bytes(5));
+        $url .= "&scope=r_liteprofile%20r_emailaddress%20w_member_social";
+
+        return $url;
+    }
+
+    public function retrieveToken(): string {
+        // Retrieve LinkedIn configuration
+        $LinkedIn = $this->locator->config->authenticationMethods->LinkedIn;
+
+        if (!isset($_REQUEST['code'])) {
+            die("Unable to get LinkedIn client code");
+        }
+
+        //create array of data to be posted to get AccessToken
+        $post_data = array(
+            'grant_type' => "authorization_code",
+            'code' => $_REQUEST['code'],
+            'redirect_uri' => $this->locator->getBasePath() . $LinkedIn->callback,
+            'client_id' => $LinkedIn->id,
+            'client_secret' => $LinkedIn->secret
+        );
+
+        //traverse array and prepare data for posting (key1=value1)
+        foreach ($post_data as $key => $value) {
+            $post_items[] = $key . '=' . $value;
+        }
+
+        //create the final string to be posted
+        $post_string = implode('&', $post_items);
+
+        //create cURL connection
+        $conn1 = curl_init('https://www.linkedin.com/oauth/v2/accessToken');
+
+        //set options
+        curl_setopt($conn1, CURLOPT_CONNECTTIMEOUT, 30);
+        curl_setopt($conn1, CURLOPT_RETURNTRANSFER, true);
+        curl_setopt($conn1, CURLOPT_SSL_VERIFYPEER, true);
+        curl_setopt($conn1, CURLOPT_FOLLOWLOCATION, 1);
+
+        //set data to be posted
+        curl_setopt($conn1, CURLOPT_POSTFIELDS, $post_string);
+
+        //perform our request
+        $result1 = curl_exec($conn1);
+        $info1 = curl_getinfo($conn1);
+
+        if ($info1['http_code'] === 200) {
+            $my_token = json_decode($result1, TRUE);
+            $access_token = $my_token['access_token'];
+            $expires_in = $my_token['expires_in'];
+            curl_close($conn1);
+        } else {
+            //show information regarding the error
+            $errorMessage = "Error: LinkedIn server response code: " . $info1['http_code'] . " - ";
+            $errorMessage .= curl_error($conn1);
+            error_log($result1);
+            curl_close($conn1);
+            http_response_code(500);
+            die($errorMessage);
+        }
+
+        // Call to API
+        $conn2 = curl_init();
+        curl_setopt($conn2, CURLOPT_URL, "https://api.linkedin.com/v2/me");
+        curl_setopt($conn2, CURLOPT_HTTPHEADER, array(
+            'Authorization: Bearer ' . $access_token
+        ));
+
+        curl_setopt($conn2, CURLOPT_RETURNTRANSFER, true);
+        $result = curl_exec($conn2);
+        $info2 = curl_getinfo($conn2);
+
+        if ($info2['http_code'] === 200) {
+            $data = json_decode($result, TRUE);
+
+            curl_close($conn2);
+
+            if (isset($data['errorCode'])) {
+                $errorMessage = $data['message'];
+                die($errorMessage);
+            }
+
+            $typedId = $data['id'];
+
+            // Recall to API for email
+            $conn2 = curl_init();
+            curl_setopt($conn2, CURLOPT_URL, "https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))");
+            curl_setopt($conn2, CURLOPT_HTTPHEADER, array(
+                'Authorization: Bearer ' . $access_token
+            ));
+
+            curl_setopt($conn2, CURLOPT_RETURNTRANSFER, true);
+            $result = curl_exec($conn2);
+            $info2 = curl_getinfo($conn2);
+
+            if ($info2['http_code'] === 200) {
+                $data2 = json_decode($result, TRUE);
+
+                curl_close($conn2);
+
+                if (isset($data['errorCode'])) {
+                    $errorMessage = $data['message'];
+                    die($errorMessage);
+                }
+            } else {
+                //show information regarding the error
+                $errorMessage = "Error: LinkedIn server response code: " . $info2['http_code'] . " - ";
+                $errorMessage = $errorMessage . curl_error($conn2);
+                curl_close($conn2);
+                die($errorMessage);
+            }
+
+            return $this->onIdentityDataReceived($typedId, function($identity) use($data, $data2) {
+                        $identity->email = $data2['elements'][0]['handle~']['emailAddress'];
+                        $identity->name = $data['localizedFirstName'];
+                        $identity->surname = $data['localizedLastName'];
+                    });
+        } else {
+            //show information regarding the error
+            $errorMessage = "Error: LinkedIn server response code: " . $info2['http_code'] . " - ";
+            $errorMessage = $errorMessage . curl_error($conn2);
+            error_log($result);
+            curl_close($conn2);
+            die($errorMessage);
+        }
+
+        return null;
+    }
+
+}
diff --git a/classes/login/LoginHandler.php b/classes/login/LoginHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..9ebdf218813fc5c4a75c31556d30e562734fbb1c
--- /dev/null
+++ b/classes/login/LoginHandler.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace RAP;
+
+class LoginHandler {
+
+    protected $locator;
+    private $identityType;
+
+    public function __construct(Locator $locator, string $identityType) {
+        $this->locator = $locator;
+        $this->identityType = $identityType;
+    }
+
+    public function onIdentityDataReceived(string $typedId, \Closure $fillIdentityData): string {
+
+        $user = $this->locator->getUserDAO()->findUserByIdentity($this->identityType, $typedId);
+
+        if ($user === null) {
+            return $this->handleNewIdentity($typedId, $fillIdentityData);
+        }
+
+        return $this->getAfterLoginRedirect($user);
+    }
+
+    protected function handleNewIdentity(string $typedId, \Closure $fillIdentityData): string {
+
+        $session = $this->locator->getSession();
+
+        if ($session->getUser() !== null && $session->getAction() === 'join') {
+            return $this->joinToPreviousUser($session->getUser(), $typedId, $fillIdentityData);
+        } else {
+            return $this->redirectToTOUCheck($typedId, $fillIdentityData);
+        }
+    }
+
+    private function joinToPreviousUser(User $user, string $typedId, \Closure $fillIdentityData): string {
+
+        $identity = new Identity($this->identityType);
+        $identity->typedId = $typedId;
+        $fillIdentityData($identity);
+
+        $user->addIdentity($identity);
+
+        $this->locator->getUserHandler()->saveUser($user);
+
+        $this->locator->getSession()->setUser($user);
+
+        return $this->getAfterLoginRedirect($user);
+    }
+
+    /**
+     * Stores the data into session and Redirect to Term of Use acceptance page.
+     */
+    private function redirectToTOUCheck(string $typedId, \Closure $fillIdentityData): string {
+
+        // Create new user
+        $user = new \RAP\User();
+
+        $identity = new Identity($this->identityType);
+        $identity->typedId = $typedId;
+        $fillIdentityData($identity);
+
+        $user->addIdentity($identity);
+
+        $this->locator->getSession()->setUser($user);
+
+        return $this->locator->getBasePath() . '/tou-check';
+    }
+
+    public function getAfterLoginRedirect(User $user): string {
+
+        $session = $this->locator->getSession();
+        $this->locator->getAuditLogger()->info("LOGIN," . $this->identityType . "," . $user->id);
+
+        if ($session->getOAuth2RequestData() !== null) {
+            $session->setUser($user);
+            $redirectUrl = $this->locator->getOAuth2RequestHandler()->getRedirectResponseUrl();
+            session_destroy();
+            return $redirectUrl;
+        }
+
+        if ($session->getAction() !== null) {
+
+            $action = $session->getAction();
+
+            if ($action === 'join') {
+                if ($session->getUser()->id !== $user->id) {
+                    $user = $this->locator->getUserHandler()->joinUsers($session->getUser(), $user);
+                }
+
+                // the join is completed
+                $action = 'account';
+                $session->setAction($action);
+            }
+
+            $session->setUser($user);
+
+            if ($action === 'account' || $action === 'admin') {
+                return $this->locator->getBasePath() . '/' . $action;
+            }
+        }
+
+        throw new \Exception("Unable to find a proper redirect");
+    }
+
+}
diff --git a/classes/login/OrcidLogin.php b/classes/login/OrcidLogin.php
new file mode 100644
index 0000000000000000000000000000000000000000..8568a73e48cbb26494ab732852228270b7860e52
--- /dev/null
+++ b/classes/login/OrcidLogin.php
@@ -0,0 +1,127 @@
+<?php
+
+namespace RAP;
+
+class OrcidLogin extends LoginHandler {
+
+    public function __construct(Locator $locator) {
+        parent::__construct($locator, Identity::ORCID);
+    }
+
+    public function login(): string {
+
+        $ORCID = $this->locator->config->authenticationMethods->OrcID;
+
+        $url = "https://orcid.org/oauth/authorize";
+        $url = $url . "?client_id=" . $ORCID->id;
+        $url = $url . "&response_type=code";
+        $url = $url . "&scope=/authenticate";
+        $url = $url . "&redirect_uri=" . $this->locator->getBasePath() . $ORCID->callback;
+
+        return $url;
+    }
+
+    public function retrieveToken($code) {
+
+        if ($code === null) {
+            throw new BadRequestException("Unable to get ORCID client code");
+        }
+
+        $token = $this->getAccessTokenFromCode($code);
+
+        $access_token = $token['access_token'];
+        $expires_in = $token['expires_in'];
+        $orcid_id = $username = $token['orcid'];
+
+        $orcid_data = $this->getOrcidDataUsingAccessToken($orcid_id, $access_token);
+
+        if (!isset($orcid_data['person']['emails']['email'][0]['email'])) {
+            throw new \Exception("ORCID didn't return the email");
+        }
+
+        return $this->onIdentityDataReceived($orcid_id, function($identity) use($orcid_data) {
+                    $identity->email = $email = $orcid_data['person']['emails']['email'][0]['email'];
+                    $identity->name = $orcid_data['person']['name']['given-names']['value'];
+                    $identity->surname = $orcid_data['person']['name']['family-name']['value'];
+                    $employmentSummary = $orcid_data['activities-summary']['employments']['employment-summary'];
+                    if (count($employmentSummary) > 0) {
+                        $identity->institution = $employmentSummary[0]['organization']['name'];
+                    }
+                });
+    }
+
+    private function getAccessTokenFromCode($code): array {
+
+        $ORCID = $this->locator->config->authenticationMethods->OrcID;
+
+        //create array of data to be posted to get AccessToken
+        $post_data = array(
+            'grant_type' => "authorization_code",
+            'code' => $code,
+            'redirect_uri' => $this->locator->getBasePath() . $ORCID->callback,
+            'client_id' => $ORCID->id,
+            'client_secret' => $ORCID->secret
+        );
+
+        //traverse array and prepare data for posting (key1=value1)
+        foreach ($post_data as $key => $value) {
+            $post_items[] = $key . '=' . $value;
+        }
+
+        //create the final string to be posted
+        $post_string = implode('&', $post_items);
+
+        //create cURL connection
+        $conn = curl_init('https://orcid.org/oauth/token');
+
+        //set options
+        curl_setopt($conn, CURLOPT_CONNECTTIMEOUT, 30);
+        curl_setopt($conn, CURLOPT_RETURNTRANSFER, true);
+        curl_setopt($conn, CURLOPT_SSL_VERIFYPEER, true);
+        curl_setopt($conn, CURLOPT_FOLLOWLOCATION, 1);
+
+        //set data to be posted
+        curl_setopt($conn, CURLOPT_POSTFIELDS, $post_string);
+
+        //perform our request
+        $result = curl_exec($conn);
+
+        if ($result) {
+            $token = json_decode($result, TRUE);
+            curl_close($conn);
+            return $token;
+        } else {
+            //show information regarding the error
+            $errorMessage = curl_errno($conn) . "-";
+            $errorMessage = $errorMessage . curl_error($conn);
+            curl_close($conn);
+            throw new \Exception($errorMessage);
+        }
+    }
+
+    private function getOrcidDataUsingAccessToken(string $orcid_id, string $access_token) {
+
+        // API call
+        $orcid_url = "https://pub.orcid.org/v2.1/" . $orcid_id . "/record";
+        $conn = curl_init();
+        curl_setopt($conn, CURLOPT_URL, $orcid_url);
+        curl_setopt($conn, CURLOPT_HTTPHEADER, array(
+            'Authorization: Bearer ' . $access_token,
+            'Accept: application/json'));
+
+        curl_setopt($conn, CURLOPT_RETURNTRANSFER, true);
+        $result = curl_exec($conn);
+
+        if ($result) {
+            $orcid_data = json_decode($result, TRUE);
+            curl_close($conn);
+            return $orcid_data;
+        } else {
+            $errorMessage = curl_errno($conn) . "-";
+            $errorMessage = $errorMessage . curl_error($conn);
+            curl_close($conn);
+            throw new \Exception($errorMessage);
+        }
+    }
+
+}
diff --git a/classes/login/ShibbolethLogin.php b/classes/login/ShibbolethLogin.php
new file mode 100644
index 0000000000000000000000000000000000000000..0d08f8f512e116d1e6d229df2901a959b98ff9f4
--- /dev/null
+++ b/classes/login/ShibbolethLogin.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace RAP;
+
+class ShibbolethLogin extends LoginHandler {
+
+    public function __construct(Locator $locator) {
+        parent::__construct($locator, Identity::EDU_GAIN);
+    }
+
+    public function login() {
+
+        if (isset($_SERVER['Shib-Session-ID'])) {
+
+            // Retrieving eduPersonPrincipalName (eppn)
+            $eppn = $_SERVER['eppn'];
+
+            // Search if the user is already registered into RAP using the eppn.
+            // The persistent id should be a more appropriate identifier, however at IA2
+            // we need to import all INAF user into RAP, even if they will never register,
+            // and in that case we know only their eppn.
+
+            return $this->onIdentityDataReceived($eppn, function($identity) use($eppn) {
+                $identity->email = $_SERVER['mail'];
+                $identity->name = $_SERVER['givenName'];
+                $identity->surname = $_SERVER['sn'];
+                $identity->eppn = $eppn;
+            });
+        } else {
+            http_response_code(500);
+            die("Shib-Session-ID not found!");
+        }
+    }
+
+}
diff --git a/classes/login/X509Login.php b/classes/login/X509Login.php
new file mode 100644
index 0000000000000000000000000000000000000000..b050bbacf4e653c625091223fa393901cae5fb41
--- /dev/null
+++ b/classes/login/X509Login.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace RAP;
+
+class X509Login extends LoginHandler {
+
+    private $x509Data;
+
+    public function __construct(Locator $locator) {
+        parent::__construct($locator, Identity::X509);
+    }
+
+    public function login(): string {
+        if (isset($_SERVER['SSL_CLIENT_VERIFY']) && isset($_SERVER['SSL_CLIENT_V_REMAIN']) &&
+                $_SERVER['SSL_CLIENT_VERIFY'] === 'SUCCESS' && $_SERVER['SSL_CLIENT_V_REMAIN'] > 0) {
+
+            $x509Data = X509Data::parse($_SERVER);
+            $this->x509Data = $x509Data;
+
+            return $this->onIdentityDataReceived($x509Data->serialNumber, function($identity) use ($x509Data) {
+                        $this->fillIdentity($identity, $x509Data);
+                    });
+        } else {
+            http_response_code(500);
+            die("Unable to verify client certificate");
+        }
+
+        return null;
+    }
+
+    public function afterNameSurnameSelection($x509Data) {
+        $redirect = $this->onIdentityDataReceived($x509Data->serialNumber, function($identity) use ($x509Data) {
+            $this->fillIdentity($identity, $x509Data);
+        });
+
+        $session = $this->locator->getSession();
+        $session->setX509DataToRegister(null);
+
+        return $redirect;
+    }
+
+    private function fillIdentity($identity, $x509Data) {
+        $identity->email = $x509Data->email;
+        $identity->name = $x509Data->name;
+        $identity->surname = $x509Data->surname;
+        $identity->institution = $x509Data->institution;
+    }
+
+    /**
+     * We want to extract name and surname from the X.509 certificate, however X.509
+     * puts name and surname together (inside the CN field).
+     * If name and surname are single words it is possible to retrieve them splitting
+     * on the space character, otherwise the user has to choose the correct combination.
+     * In that case partial X.509 data is temporarily stored into the user session and
+     * the page views/x509-name-surname.php is shown to the user before completing the
+     * registration, in order to allow him/her selecting the correct name and surname.
+     */
+    protected function handleNewIdentity(string $typedId, \Closure $fillIdentityData): string {
+
+        $session = $this->locator->getSession();
+
+        if ($this->x509Data->name === null) {
+            $session->setX509DataToRegister($this->x509Data);
+            return $this->locator->getBasePath() . '/x509-name-surname';
+        } else {
+            return parent::handleNewIdentity($typedId, $fillIdentityData);
+        }
+    }
+
+}
diff --git a/classes/model/AccessTokenData.php b/classes/model/AccessTokenData.php
new file mode 100644
index 0000000000000000000000000000000000000000..3826e22a14a040dceb6464a8b2969f58c891e9e8
--- /dev/null
+++ b/classes/model/AccessTokenData.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace RAP;
+
+/**
+ * Data related to access tokens stored into the database. This object is
+ * retrieved from the database (using the code or the refresh token hashes) and
+ * it is used to generate OAuth2 tokens.
+ */
+class AccessTokenData {
+
+    private const TOKEN_LIFESPAN = 3600;
+
+    public $id;
+    public $codeHash;
+    public $userId;
+    public $creationTime;
+    public $expirationTime;
+    public $redirectUri;
+    public $clientId;
+    public $scope;
+
+    public function __construct() {
+        $this->creationTime = time();
+        $this->expirationTime = $this->creationTime + AccessTokenData::TOKEN_LIFESPAN;
+    }
+
+    public function isExpired(): bool {
+        return $this->expirationTime < time();
+    }
+
+}
diff --git a/classes/model/AuthPageModel.php b/classes/model/AuthPageModel.php
new file mode 100644
index 0000000000000000000000000000000000000000..bf90c89f2b2d5baf68f675cfc45bd827dc580b5e
--- /dev/null
+++ b/classes/model/AuthPageModel.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace RAP;
+
+/**
+ * Model for the main RAP page (authentication method choice).
+ */
+class AuthPageModel {
+
+    // boolean flags
+    public $eduGAIN;
+    public $orcid;
+    public $x509;
+    public $google;
+    public $facebook;
+    public $linkedIn;
+    public $localIdP;
+    //
+    public $clientIcon;
+    public $clientTitle;
+    public $localIdPConfig;
+
+    public function __construct(\RAP\Locator $locator, \RAP\RAPClient $client) {
+
+        $config = $locator->config;
+
+        $this->setupAuthenticationMethodFlags($config, $client);
+
+        if ($this->localIdP) {
+            $this->localIdPConfig = $config->authenticationMethods->LocalIdP;
+        }
+
+        if (isset($client->icon)) {
+            $this->clientIcon = $client->getIconBasePath() . $client->icon;
+        }
+        $this->clientTitle = $client->title;
+    }
+
+    private function setupAuthenticationMethodFlags($config, $client) {
+
+        $this->eduGAIN = isset($config->authenticationMethods->eduGAIN) &&
+                in_array(AuthenticationMethods::EDU_GAIN, $client->authMethods);
+
+        $this->orcid = isset($config->authenticationMethods->OrcID) &&
+                in_array(AuthenticationMethods::ORCID, $client->authMethods);
+
+        $this->x509 = isset($config->authenticationMethods->X509) &&
+                in_array(AuthenticationMethods::X509, $client->authMethods);
+
+        $this->google = isset($config->authenticationMethods->Google) &&
+                in_array(AuthenticationMethods::GOOGLE, $client->authMethods);
+
+        $this->facebook = isset($config->authenticationMethods->Facebook) &&
+                in_array(AuthenticationMethods::FACEBOOK, $client->authMethods);
+
+        $this->linkedIn = isset($config->authenticationMethods->LinkedIn) &&
+                in_array(AuthenticationMethods::LINKED_IN, $client->authMethods);
+
+        $this->localIdP = isset($config->authenticationMethods->LocalIdP) &&
+                in_array(AuthenticationMethods::LOCAL_IDP, $client->authMethods);
+    }
+
+}
diff --git a/classes/model/AuthenticationMethods.php b/classes/model/AuthenticationMethods.php
new file mode 100644
index 0000000000000000000000000000000000000000..8b3f29d59053021b427c6a6fbe3a483aafd88abc
--- /dev/null
+++ b/classes/model/AuthenticationMethods.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace RAP;
+
+abstract class AuthenticationMethods {
+
+    const EDU_GAIN = "eduGAIN";
+    const ORCID = "OrcID";
+    const X509 = "X.509";
+    const GOOGLE = "Google";
+    const LINKED_IN = "LinkedIn";
+    const FACEBOOK = "Facebook";
+    const LOCAL_IDP = "LocalIdP";
+
+    public static function getAllMethods() {
+        return [
+            AuthenticationMethods::EDU_GAIN,
+            AuthenticationMethods::ORCID,
+            AuthenticationMethods::X509,
+            AuthenticationMethods::GOOGLE,
+            AuthenticationMethods::LINKED_IN,
+            AuthenticationMethods::FACEBOOK,
+            AuthenticationMethods::LOCAL_IDP
+        ];
+    }
+
+}
diff --git a/classes/Identity.php b/classes/model/Identity.php
similarity index 98%
rename from classes/Identity.php
rename to classes/model/Identity.php
index 7710595fe534008d4fc00b0cdfb05c53971fb6cc..e0ba59240301dac14857cbdba6a838429cc64767 100644
--- a/classes/Identity.php
+++ b/classes/model/Identity.php
@@ -34,8 +34,9 @@ class Identity {
     const GOOGLE = "Google";
     const FACEBOOK = "Facebook";
     const LINKEDIN = "LinkedIn";
+    const ORCID = "OrcID";
 
-    private static $ALLOWED_TYPES = [Identity::EDU_GAIN, Identity::X509, Identity::GOOGLE, Identity::FACEBOOK, Identity::LINKEDIN];
+    private static $ALLOWED_TYPES = [Identity::EDU_GAIN, Identity::X509, Identity::GOOGLE, Identity::FACEBOOK, Identity::LINKEDIN, Identity::ORCID];
 
     /**
      * Identity id in the database. Mandatory field.
diff --git a/classes/model/InternalClient.php b/classes/model/InternalClient.php
new file mode 100644
index 0000000000000000000000000000000000000000..f91f7d0077244fca781c50469a6dcdbdc64e66d6
--- /dev/null
+++ b/classes/model/InternalClient.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace RAP;
+
+/**
+ * Represents a client that connects to parts of RAP itself (e.g. Account Manager).
+ * It doesn't use OAuth2, instead stores data directly into the PHP session.
+ */
+class InternalClient extends RAPClient {
+
+    public $action;
+
+    public function __construct(string $action) {
+        $this->authMethods = AuthenticationMethods::getAllMethods();
+        $this->action = $action;
+    }
+
+    public function getIconBasePath() {
+        return 'service-logos/';
+    }
+
+}
diff --git a/classes/Util.php b/classes/model/OAuth2Client.php
similarity index 70%
rename from classes/Util.php
rename to classes/model/OAuth2Client.php
index 4879b9b08d3005625644a1736b2202b030093860..acad698f384be71d3ae51df48f1d7389e514dd99 100644
--- a/classes/Util.php
+++ b/classes/model/OAuth2Client.php
@@ -6,7 +6,7 @@
  *               OATS - Astronomical Observatory - Trieste
  * ----------------------------------------------------------------------------
  *
- * Copyright (C) 2016 Istituto Nazionale di Astrofisica
+ * Copyright (C) 2019 Istituto Nazionale di Astrofisica
  *
  * This program is free software; you can redistribute it and/or modify it under
  * the terms of the GNU General Public License Version 3 as published by the
@@ -25,16 +25,21 @@
 namespace RAP;
 
 /**
- * Utility class
+ * Data model for storing information about a RAP client connecting using OAuth2.
  */
-class Util {
+class OAuth2Client extends RAPClient {
 
-    /**
-     * @return string random string
-     */
-    public static function createNewToken() {
-        // Credits: http://stackoverflow.com/a/18890309/771431
-        return bin2hex(openssl_random_pseudo_bytes(16));
+    public $id;
+    public $client;
+    public $secret;
+    public $redirectUrl;
+    public $scope;
+    public $homePage;
+    public $showInHome;
+    public $scopeAudienceMap = [];
+
+    public function getIconBasePath() {
+        return 'client-icons/';
     }
 
 }
diff --git a/classes/model/OAuth2RequestData.php b/classes/model/OAuth2RequestData.php
new file mode 100644
index 0000000000000000000000000000000000000000..bac7778be8a6f9e72bba1c5ed1f071535ed20c6f
--- /dev/null
+++ b/classes/model/OAuth2RequestData.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace RAP;
+
+/**
+ * Data model for OAuth2 request.
+ */
+class OAuth2RequestData {
+
+    public $clientId;
+    public $redirectUrl;
+    public $state;
+    public $scope;
+    public $nonce;
+
+}
diff --git a/classes/model/RAPClient.php b/classes/model/RAPClient.php
new file mode 100644
index 0000000000000000000000000000000000000000..e6a55e8fc30f0c4d777fe23ef9a4e33f400f92f2
--- /dev/null
+++ b/classes/model/RAPClient.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace RAP;
+
+abstract class RAPClient {
+
+    public $title;
+    public $icon;
+    // list of AuthN methods supported by the client
+    public $authMethods = [];
+
+    public abstract function getIconBasePath();
+}
diff --git a/classes/model/RSAKeyPair.php b/classes/model/RSAKeyPair.php
new file mode 100644
index 0000000000000000000000000000000000000000..ac224585c130348be20a92f6152a8c9ac55faac5
--- /dev/null
+++ b/classes/model/RSAKeyPair.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace RAP;
+
+class RSAKeyPair {
+    
+    public $keyId;
+    public $privateKey;
+    public $publicKey;
+    public $alg;
+    public $creationTime;
+}
\ No newline at end of file
diff --git a/classes/model/RefreshTokenData.php b/classes/model/RefreshTokenData.php
new file mode 100644
index 0000000000000000000000000000000000000000..22e18d18f30a6658f66ce07b2d18c36b86dfd685
--- /dev/null
+++ b/classes/model/RefreshTokenData.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace RAP;
+
+class RefreshTokenData {
+
+    private const TOKEN_LIFESPAN = 2 * 3600;
+
+    public function __construct() {
+        $this->creationTime = time();
+        $this->expirationTime = $this->creationTime + RefreshTokenData::TOKEN_LIFESPAN;
+    }
+
+    public $tokenHash;
+    public $userId;
+    public $creationTime;
+    public $expirationTime;
+    public $clientId;
+    public $scope;
+
+    public function isExpired(): bool {
+        return $this->expirationTime < time();
+    }
+
+}
diff --git a/classes/model/SessionData.php b/classes/model/SessionData.php
new file mode 100644
index 0000000000000000000000000000000000000000..a560b6a03214691bcf9e194714372e5c0889f29c
--- /dev/null
+++ b/classes/model/SessionData.php
@@ -0,0 +1,96 @@
+<?php
+
+/* ----------------------------------------------------------------------------
+ *               INAF - National Institute for Astrophysics
+ *               IRA  - Radioastronomical Institute - Bologna
+ *               OATS - Astronomical Observatory - Trieste
+ * ----------------------------------------------------------------------------
+ *
+ * Copyright (C) 2016 Istituto Nazionale di Astrofisica
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License Version 3 as published by the
+ * Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+ * details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program; if not, write to the Free Software Foundation, Inc., 51
+ * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+namespace RAP;
+
+/**
+ * This class wraps various objects that need to be stored into the session in
+ * order to provide an object oriented transparent session management.
+ */
+class SessionData {
+
+    const KEY = "SessionData";
+
+    private $user;
+    private $x509DataToRegister;
+    private $oauth2RequestData;
+    private $action;
+
+    public function setUser(?User $user): void {
+        $this->user = $user;
+        $this->save();
+    }
+
+    public function getUser(): ?User {
+        return $this->user;
+    }
+
+    /**
+     * Update the user data model stored into the session after the primary
+     * identity has changed, in order to avoid reading again the user data from
+     * the database.
+     * @param int $identityId
+     */
+    public function updatePrimaryIdentity($identityId): void {
+        foreach ($this->user->identities as $identity) {
+            $identity->primary = ($identity->id === $identityId);
+        }
+        $this->save();
+    }
+
+    public function setX509DataToRegister(?X509Data $x509DataToRegister): void {
+        $this->x509DataToRegister = $x509DataToRegister;
+        $this->save();
+    }
+
+    public function getX509DataToRegister(): ?X509Data {
+        return $this->x509DataToRegister;
+    }
+
+    public function setOAuth2RequestData(?OAuth2RequestData $oauth2RequestData): void {
+        $this->oauth2RequestData = $oauth2RequestData;
+        $this->save();
+    }
+
+    public function getOAuth2RequestData(): ?OAuth2RequestData {
+        return $this->oauth2RequestData;
+    }
+
+    public function setAction(?string $action): void {
+        $this->action = $action;
+        $this->save();
+    }
+
+    public function getAction(): ?string {
+        return $this->action;
+    }
+
+    /**
+     * Store the data into the $_SESSION PHP variable
+     */
+    public function save() {
+        $_SESSION[SessionData::KEY] = $this;
+    }
+
+}
diff --git a/classes/model/User.php b/classes/model/User.php
new file mode 100644
index 0000000000000000000000000000000000000000..8f27e9c1a6627b31d705b7af90dcc663fdee83b6
--- /dev/null
+++ b/classes/model/User.php
@@ -0,0 +1,131 @@
+<?php
+
+/* ----------------------------------------------------------------------------
+ *               INAF - National Institute for Astrophysics
+ *               IRA  - Radioastronomical Institute - Bologna
+ *               OATS - Astronomical Observatory - Trieste
+ * ----------------------------------------------------------------------------
+ *
+ * Copyright (C) 2016 Istituto Nazionale di Astrofisica
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License Version 3 as published by the
+ * Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+ * details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program; if not, write to the Free Software Foundation, Inc., 51
+ * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+namespace RAP;
+
+/**
+ * Data model for the user. An user is a set of identities.
+ */
+class User {
+
+    // User ID
+    public $id;
+    // List of identities
+    public $identities;
+
+    public function __construct() {
+        $this->identities = [];
+    }
+
+    public function addIdentity(Identity $identity): void {
+        array_push($this->identities, $identity);
+    }
+
+    public function getPrimaryEmail() {
+        foreach ($this->identities as $identity) {
+            if ($identity->primary) {
+                return $identity->email;
+            }
+        }
+        // A primary identity MUST be defined
+        throw new \Exception("No primary identity defined for user " . $this->id);
+    }
+
+    /**
+     * Returns name and surname if they are present, preferring the primary identity data.
+     */
+    public function getCompleteName(): ?string {
+
+        $completeName = null;
+
+        foreach ($this->identities as $identity) {
+            if ($identity->name !== null && $identity->surname !== null) {
+                $completeName = $identity->name . ' ' . $identity->surname;
+            }
+            if ($identity->primary && $completeName !== null) {
+                break;
+            }
+        }
+
+        return $completeName;
+    }
+
+    /**
+     * Returns the user name if it is present, preferring the primary identity data.
+     */
+    public function getName(): ?string {
+
+        $name = null;
+
+        foreach ($this->identities as $identity) {
+            if ($identity->name !== null) {
+                $name = $identity->name;
+            }
+            if ($identity->primary && $name !== null) {
+                break;
+            }
+        }
+
+        return $name;
+    }
+
+    /**
+     * Returns the user surname if it is present, preferring the primary identity data.
+     */
+    public function getSurname(): ?string {
+
+        $surname = null;
+
+        foreach ($this->identities as $identity) {
+            if ($identity->surname !== null) {
+                $surname = $identity->surname;
+            }
+            if ($identity->primary && $surname !== null) {
+                break;
+            }
+        }
+
+        return $surname;
+    }
+
+    /**
+     * Returns the user institution if it is present, preferring the primary identity data.
+     */
+    public function getInstitution(): ?string {
+
+        $institution = null;
+
+        foreach ($this->identities as $identity) {
+            if ($identity->institution !== null) {
+                $institution = $identity->institution;
+            }
+            if ($identity->primary && $institution !== null) {
+                break;
+            }
+        }
+
+        return $institution;
+    }
+
+}
diff --git a/composer.json b/composer.json
index a49af190f691b89e0b02da0048ed489b96ba7a15..41daa1a14bea5fe2192f5d5a46fe59163934fab1 100644
--- a/composer.json
+++ b/composer.json
@@ -1,9 +1,20 @@
 {
+    "name": "ia2/rap",
+    "description": "Remote Authentication Portal",
+    "license": "GPL-3.0-or-later",
     "require": {
-        "mikecao/flight": "1.3.2",
+        "mikecao/flight": "1.3.7",
         "google/apiclient": "2.1.3",
         "facebook/graph-sdk": "^5.5",
         "monolog/monolog": "^1.22",
         "phpmailer/phpmailer": "^6.0"
+    },
+    "require-dev": {
+        "phpunit/phpunit": "^8.2"
+    },
+    "autoload": {
+        "classmap": [
+            "classes/"
+        ]
     }
 }
diff --git a/config-example.json b/config-example.json
new file mode 100644
index 0000000000000000000000000000000000000000..4496e8a524c874d73c07868c75fc6d68d6976c6c
--- /dev/null
+++ b/config-example.json
@@ -0,0 +1,58 @@
+{
+    "contextRoot": "/rap-ia2",
+    "serviceLogFile": "/var/www/html/rap-ia2/logs/rap-service.log",
+    "auditLogFile": "/var/www/html/rap-ia2/logs/rap-audit.log",
+    "timeZone": "Europe/Rome",
+    "logLevel": "DEBUG",
+    "jwtIssuer": "sso.ia2.inaf.it",
+    "databaseConfig": {
+        "dbtype": "MySQL",
+        "hostname": "localhost",
+        "port": 3306,
+        "username": "rap",
+        "password": "XXXXXX",
+        "dbname": "rap"
+    },
+    "authenticationMethods": {
+        "eduGAIN": {},
+        "Google": {
+            "id": "XXXXXX",
+            "secret": "XXXXXX",
+            "callback": "/auth/social/google"
+        },
+        "Facebook": {
+            "id": "XXXXXX",
+            "secret": "XXXXXX",
+            "version": "v3.0",
+            "callback": "/auth/social/facebook/token"
+        },
+        "LinkedIn": {
+            "id": "XXXXXX",
+            "secret": "XXXXXX",
+            "callback": "/auth/social/linkedin/token"
+        },
+        "X509": {},
+        "LocalIdP": {
+            "url": "https://sso.ia2.inaf.it/Shibboleth.sso/Login?entityID=https://sso.ia2.inaf.it/idp/shibboleth&target=https://sso.ia2.inaf.it/rap-ia2/auth/saml2/aai.php",
+            "logo": "img/ia2-logo-60x60.png",
+            "logoAlt": "IA2 logo",
+            "description": "Use the IA2 Logo to Login if you have an account provided by IA2 or self registered"
+        },
+        "OrcID": {
+            "id": "",
+            "callback": "/auth/orcid",
+            "secret": ""
+        }
+    },
+    "gms": {
+        "id": "gms",
+        "joinEndpoint": "http://localhost:8082/gms/ws/jwt/join"
+    },
+    "tokenIssuer": {
+        "services": [{
+                "id": "fileserver",
+                "label": "File Server"
+            }],
+        "lifespans": [1, 6, 12, 24]
+    }
+}
diff --git a/config-example.php b/config-example.php
index 5c2fabcb6fc794c4dcc10dc5febede097c9c5bdb..49b10f7a071183b4b1ec29c92fd40d5567af0a30 100644
--- a/config-example.php
+++ b/config-example.php
@@ -59,16 +59,16 @@ $AUTHENTICATION_METHODS = array(
     'Google' => array(
         'id' => "XXXXXX",
         'secret' => "XXXXXX",
-        'callback' => $BASE_PATH . "/auth/oauth2/google_token.php"),
+        'callback' => $BASE_PATH . "/auth/social/google_token.php"),
     'Facebook' => array(
         'id' => "XXXXXX",
         'secret' => "XXXXXX",
         'version' => "v3.0",
-        'callback' => $BASE_PATH . "/auth/oauth2/facebook_token.php"),
+        'callback' => $BASE_PATH . "/auth/social/facebook_token.php"),
     'LinkedIn' => array(
         'id' => 'XXXXXX',
         'secret' => 'XXXXXX',
-        'callback' => $BASE_PATH . '/auth/oauth2/linkedin_token.php'
+        'callback' => $BASE_PATH . '/auth/social/linkedin_token.php'
     ),
     'X.509' => array(),
     'DirectIdP' => array(
diff --git a/css/style.css b/css/style.css
index 021218b894fe81a217d6e3e322c0b01bc07b31b1..53aa45bb0555f9b05f478972580db200e04f0621 100644
--- a/css/style.css
+++ b/css/style.css
@@ -188,4 +188,32 @@ body {
 .service-logo {
     padding-right: 10px;
     max-height: 50px;
+}
+
+#token-issuer-btn {
+    margin-top: 20px;
+    margin-bottom: 20px;
+}
+
+.circle-wrapper {
+    text-align: center;
+    position: relative;
+}
+
+.circle {
+    width: 90px;
+    height: 90px;
+    border-radius: 50%;
+    font-size: 25px;
+    color: #fff;
+    line-height: 30px;
+    text-align: center;
+    background: red;
+    transform: rotate(-20deg);
+    padding-top: 6px;
+    border: 3px yellow solid;
+    font-weight: bold;
+    position: absolute;
+    left: 27px;
+    top: 0px;
 }
\ No newline at end of file
diff --git a/exec/.htaccess b/exec/.htaccess
new file mode 100644
index 0000000000000000000000000000000000000000..0819c675015a39eeb64ced3cc76d4cb17c3e37f8
--- /dev/null
+++ b/exec/.htaccess
@@ -0,0 +1,4 @@
+# This directory is for programmatical execution of php scripts
+# e.g. generating new keypairs from a cron job
+
+Deny from  all
diff --git a/exec/generate-keypair.php b/exec/generate-keypair.php
new file mode 100644
index 0000000000000000000000000000000000000000..beb7663f9979d5d7f1475d15f7a45e76bdf0c7e7
--- /dev/null
+++ b/exec/generate-keypair.php
@@ -0,0 +1,10 @@
+<?php
+
+chdir(dirname(__FILE__));
+
+include '../include/init.php';
+
+$handler = new \RAP\JWKSHandler($locator);
+$handler->generateKeyPair();
+
+echo "OK";
\ No newline at end of file
diff --git a/exec/join.php b/exec/join.php
new file mode 100644
index 0000000000000000000000000000000000000000..0f42b1bacb9cdfb64fa262951dde10b1c6509db5
--- /dev/null
+++ b/exec/join.php
@@ -0,0 +1,31 @@
+<?php
+
+if($argc !== 3) {
+    echo "Usage: php $argv[0] <user_id_1> <user_id_2>\n";
+    echo "The second id will be deleted.\n";
+    exit(1);
+}
+
+chdir(dirname(__FILE__));
+
+include '../include/init.php';
+
+$dao = $locator->getUserDAO();
+$handler = $locator->getUserHandler();
+$tokenBuilder = $locator->getTokenBuilder();
+
+$user1 = $dao->findUserById((int) $argv[1]);
+if($user1 === null) {
+    echo "User $argv[1] not found";
+    exit(1);
+}
+
+$user2 = $dao->findUserById((int) $argv[2]);
+if($user2 === null) {
+    echo "User $argv[2] not found";
+    exit(1);
+}
+
+$handler->joinUsers($user1, $user2);
+
+echo "OK\n";
diff --git a/img/eduGain-200.png b/img/eduGain-200.png
deleted file mode 100755
index 5235aa8fcd435d54d4e4bec1374f6aa151a0c3d4..0000000000000000000000000000000000000000
Binary files a/img/eduGain-200.png and /dev/null differ
diff --git a/img/edugain-100.png b/img/edugain-100.png
new file mode 100644
index 0000000000000000000000000000000000000000..c24fbd52e91725f7686df9f23f6358274129ffeb
Binary files /dev/null and b/img/edugain-100.png differ
diff --git a/img/orcid-100.png b/img/orcid-100.png
new file mode 100644
index 0000000000000000000000000000000000000000..d7ba058ba9c8f4fda93cca35c585a7c00b24499d
Binary files /dev/null and b/img/orcid-100.png differ
diff --git a/include/admin.php b/include/admin.php
new file mode 100644
index 0000000000000000000000000000000000000000..3e3b7fafade74e09d1f090f98b17811460ff1711
--- /dev/null
+++ b/include/admin.php
@@ -0,0 +1,110 @@
+<?php
+
+/**
+ * Functionalities for the admin panel.
+ */
+//
+
+function checkUser() {
+
+    session_start();
+    global $locator;
+
+    $session = $locator->getSession();
+    if ($session->getUser() === null) {
+        http_response_code(401);
+        die("You must be registered to perform this action");
+    }
+
+    $dao = $locator->getUserDAO();
+    if (!$dao->isAdmin($session->getUser()->id)) {
+        die("You must be an admin to perform this action");
+    }
+}
+
+Flight::route('GET /admin', function() {
+    checkUser();
+
+    global $VERSION;
+    Flight::render('admin/index.php', array('title' => 'Admin panel',
+        'version' => $VERSION));
+});
+
+Flight::route('GET /admin/oauth2_clients', function() {
+
+    checkUser();
+    global $locator;
+
+    $clients = $locator->getOAuth2ClientDAO()->getOAuth2Clients();
+
+    Flight::json($clients);
+});
+
+Flight::route('POST /admin/oauth2_clients', function() {
+
+    checkUser();
+    global $locator;
+
+    $client = $locator->getOAuth2ClientDAO()->createOAuth2Client(buildOAuth2ClientFromData());
+
+    Flight::json($client);
+});
+
+Flight::route('PUT /admin/oauth2_clients', function() {
+
+    checkUser();
+    global $locator;
+
+    $client = $locator->getOAuth2ClientDAO()->updateOAuth2Client(buildOAuth2ClientFromData());
+
+    Flight::json($client);
+});
+
+Flight::route('DELETE /admin/oauth2_clients/@id', function($id) {
+
+    checkUser();
+    global $locator;
+
+    $locator->getOAuth2ClientDAO()->deleteOAuth2Client($id);
+
+    // Return no content
+    Flight::halt(204);
+});
+
+Flight::route('POST /admin/keypair', function() {
+
+    checkUser();
+    global $locator;
+
+    $keyPair = $locator->getJWKSHandler()->generateKeyPair();
+    Flight::json([
+        "id" => $keyPair->keyId
+    ]);
+});
+
+function buildOAuth2ClientFromData() {
+
+    $data = Flight::request()->data;
+    $client = new \RAP\OAuth2Client();
+
+    if (isset($data)) {
+        if (isset($data['id'])) {
+            $client->id = $data['id'];
+        }
+        $client->title = $data['title'];
+        $client->icon = $data['icon'];
+        $client->client = $data['client'];
+        $client->secret = $data['secret'];
+        $client->redirectUrl = $data['redirectUrl'];
+        $client->scope = $data['scope'];
+        $client->homePage = $data['homePage'];
+        $client->showInHome = $data['showInHome'];
+    }
+    if (isset($data['authMethods'])) {
+        foreach ($data['authMethods'] as $method) {
+            array_push($client->authMethods, $method);
+        }
+    }
+
+    return $client;
+}
diff --git a/include/front-controller.php b/include/front-controller.php
index 1fbabb77224c1ad6fe6b71ec73fc299ca40cd100..31ecec6e458aece9f798435de78000d1fbe9b8ee 100644
--- a/include/front-controller.php
+++ b/include/front-controller.php
@@ -3,7 +3,7 @@
 /**
  * Front Controller using http://flightphp.com/
  * In all these calls user session must exist, so we have to start it at the 
- * beginning using the startSession() function.
+ * beginning using the session_start() function.
  */
 //
 
@@ -22,140 +22,223 @@ function setCallback($callback) {
  * services list if a valid callback is not found
  */
 Flight::route('/', function() {
-    startSession();
-    $callback = setCallback(Flight::request()->data['callback']);
-    global $session, $callbackHandler, $BASE_PATH, $AUTHENTICATION_METHODS, $VERSION;
-    if ($callback === null && $session->user === null) {
-        Flight::render('services-list.php', array('title' => 'RAP',
-            'version' => $VERSION,
-            'action' => $BASE_PATH . '/'));
-    } else if ($callback !== null && $session->user !== null) {
-        $redirectURL = $callbackHandler->getLoginWithTokenURL($session->user->id, $callback);
-        Flight::redirect($redirectURL);
-    } else {
-        Flight::render('index.php', array('title' => 'RAP',
-            'version' => $VERSION,
-            'session' => $session, 'auth' => $AUTHENTICATION_METHODS));
+
+    session_start();
+    global $locator;
+
+    $action = Flight::request()->query['action'];
+    $locator->getSession()->setAction($action);
+
+    switch ($action) {
+        case "oauth2client":
+            $clientId = $locator->getSession()->getOAuth2RequestData()->clientId;
+            $client = $locator->getOAuth2ClientDAO()->getOAuth2ClientByClientId($clientId);
+            $authPageModel = new \RAP\AuthPageModel($locator, $client);
+            renderMainPage($authPageModel);
+            break;
+        case "account":
+            $client = new \RAP\InternalClient('account');
+            $client->icon = 'account-manager.png';
+            $client->title = 'Account Management';
+            $authPageModel = new \RAP\AuthPageModel($locator, $client);
+            renderMainPage($authPageModel);
+            break;
+        case "join":
+            $client = new \RAP\InternalClient('account');
+            $client->title = 'Join identities';
+            $authPageModel = new \RAP\AuthPageModel($locator, $client);
+            renderMainPage($authPageModel);
+            break;
+        default:
+            session_destroy();
+            $clients = $locator->getOAuth2ClientDAO()->getOAuth2Clients();
+            Flight::render('services-list.php', array('title' => 'RAP',
+                'version' => $locator->getVersion(),
+                'clients' => $clients,
+                'action' => $locator->getBasePath() . '/'));
+            break;
+    }
+});
+
+function renderMainPage(RAP\AuthPageModel $authPageModel) {
+    global $locator;
+    Flight::render('main-page.php', array('title' => 'RAP',
+        'version' => $locator->getVersion(), 'model' => $authPageModel));
+}
+
+Flight::route('GET /auth/oauth2/authorize', function() {
+
+    session_start();
+    global $locator;
+
+    $params = [
+        "client_id" => filter_input(INPUT_GET, 'client_id', FILTER_SANITIZE_STRING),
+        "redirect_uri" => filter_input(INPUT_GET, 'redirect_uri', FILTER_SANITIZE_STRING),
+        "alg" => filter_input(INPUT_GET, 'alg', FILTER_SANITIZE_STRING),
+        "state" => filter_input(INPUT_GET, 'state', FILTER_SANITIZE_STRING),
+        "scope" => filter_input(INPUT_GET, 'scope', FILTER_SANITIZE_STRING),
+        "nonce" => filter_input(INPUT_GET, 'nonce', FILTER_SANITIZE_STRING)
+    ];
+
+    $requestHandler = new \RAP\OAuth2RequestHandler($locator);
+    $requestHandler->handleAuthorizeRequest($params);
+
+    Flight::redirect('/?action=oauth2client');
+});
+
+Flight::route('POST /auth/oauth2/token', function() {
+
+    global $locator;
+
+    $params = [
+        "grant_type" => filter_input(INPUT_POST, "grant_type", FILTER_SANITIZE_STRING),
+        "code" => filter_input(INPUT_POST, "code", FILTER_SANITIZE_STRING),
+        "redirect_uri" => filter_input(INPUT_POST, "redirect_uri", FILTER_SANITIZE_STRING),
+        "refresh_token" => filter_input(INPUT_POST, "refresh_token", FILTER_SANITIZE_STRING),
+        "scope" => filter_input(INPUT_POST, "scope", FILTER_SANITIZE_STRING)
+    ];
+
+    if ($params['grant_type'] === null) {
+        throw new \RAP\BadRequestException("grant_type is required");
+    }
+
+    $requestHandler = new \RAP\OAuth2RequestHandler($locator);
+
+    switch ($params['grant_type']) {
+        case "authorization_code":
+            $token = $requestHandler->handleAccessTokenRequest($params);
+            break;
+        case "refresh_token":
+            $token = $requestHandler->handleRefreshTokenRequest($params);
+            break;
+        default:
+            throw new \RAP\BadRequestException("Unsupported grant type " . $params['grant_type']);
     }
+
+    Flight::json($token);
+});
+
+Flight::route('POST /auth/oauth2/check_token', function() {
+
+    global $locator;
+
+    $requestHandler = new \RAP\OAuth2RequestHandler($locator);
+    $result = $requestHandler->handleCheckTokenRequest();
+
+    Flight::json($result);
+});
+
+Flight::route('GET /auth/oidc/jwks', function() {
+
+    global $locator;
+
+    $jwksHandler = new \RAP\JWKSHandler($locator);
+    $jwks = $jwksHandler->getJWKS();
+
+    Flight::json($jwks);
 });
 
 Flight::route('GET /logout', function() {
-    startSession();
+    session_start();
     session_destroy();
     Flight::redirect('/');
 });
 
 function sendAuthRedirect($url) {
-    startSession();
+    session_start();
     // reload callback from query to avoid problem with session shared between 
     // multiple browser tabs
     setCallback(Flight::request()->query['callback']);
     Flight::redirect($url);
 }
 
-Flight::route('/google', function() {
-    sendAuthRedirect('/auth/oauth2/google_token.php');
+Flight::route('/auth/social/google', function() {
+    session_start();
+    global $locator;
+    $googleLogin = new \RAP\GoogleLogin($locator);
+    $redirect = $googleLogin->login();
+    if ($redirect !== null) {
+        Flight::redirect($redirect);
+    }
 });
 
-Flight::route('/facebook', function() {
-    sendAuthRedirect('/auth/oauth2/facebook_login.php');
+Flight::route('/auth/social/facebook', function() {
+    session_start();
+    global $locator;
+    $facebookLogin = new \RAP\FacebookLogin($locator);
+    Flight::redirect($facebookLogin->login());
 });
 
-Flight::route('/linkedIn', function() {
-    sendAuthRedirect('/auth/oauth2/linkedin_login.php');
+Flight::route('/auth/social/facebook/token', function() {
+    session_start();
+    global $locator;
+    $facebookLogin = new \RAP\FacebookLogin($locator);
+    Flight::redirect($facebookLogin->retrieveToken());
 });
 
-Flight::route('/eduGAIN', function() {
-    sendAuthRedirect('/auth/saml2/aai.php');
+Flight::route('/auth/social/linkedIn', function() {
+    session_start();
+    global $locator;
+    $linkedInLogin = new \RAP\LinkedInLogin($locator);
+    Flight::redirect($linkedInLogin->login());
 });
 
-Flight::route('/x509', function() {
-    sendAuthRedirect('/auth/x509/certlogin.php');
+Flight::route('/auth/social/linkedIn/token', function() {
+    session_start();
+    global $locator;
+    $linkedInLogin = new \RAP\LinkedInLogin($locator);
+    Flight::redirect($linkedInLogin->retrieveToken());
 });
 
-Flight::route('/direct', function() {
-    global $AUTHENTICATION_METHODS;
-    sendAuthRedirect($AUTHENTICATION_METHODS['DirectIdP']['url']);
+Flight::route('/auth/orcid', function() {
+    session_start();
+    global $locator;
+    $orcidLogin = new \RAP\OrcidLogin($locator);
+    Flight::redirect($orcidLogin->login());
 });
 
-/**
- * Render the join confirmation page (confirmation link received by email).
- */
-Flight::route('GET /confirm-join', function() {
-    $token = Flight::request()->query['token'];
-
-    if ($token === null) {
-        http_response_code(422);
-        die("Token not found");
-    }
-
-    global $dao, $VERSION;
-
-    $userIds = $dao->findJoinRequest($token);
-    if ($userIds === null) {
-        http_response_code(422);
-        die("Invalid token");
-    }
-
-    $applicantUser = $dao->findUserById($userIds[0]);
-    $targetUser = $dao->findUserById($userIds[1]);
-
-    Flight::render('confirm-join.php', array('title' => 'RAP',
-        'version' => $VERSION,
-        'token' => $token,
-        'applicantUser' => $applicantUser,
-        'targetUser' => $targetUser));
+Flight::route('/auth/orcid/token', function() {
+    session_start();
+    global $locator;
+    $code = filter_input(INPUT_GET, 'code', FILTER_SANITIZE_STRING);
+    $orcidLogin = new \RAP\OrcidLogin($locator);
+    Flight::redirect($orcidLogin->retrieveToken($code));
 });
 
-/**
- * Confirm a join and show the page containing the operation status.
- */
-Flight::route('POST /confirm-join', function() {
-
-    global $dao, $userHandler, $auditLog;
-
-    $token = Flight::request()->data['token'];
-
-    if ($token === null) {
-        http_response_code(422);
-        die("Token not found");
-    }
-
-    $userIds = $dao->findJoinRequest($token);
-    if ($userIds === null) {
-        http_response_code(422);
-        die("Invalid token");
-    }
-
-    $auditLog->info("JOIN," . $userIds[0] . "," . $userIds[1]);
-
-    $userHandler->joinUsers($userIds[0], $userIds[1]);
-    $dao->deleteJoinRequest($token);
+Flight::route('/auth/eduGAIN', function() {
+    session_start();
+    global $locator;
+    $shibbolethLogin = new \RAP\ShibbolethLogin($locator);
+    Flight::redirect($shibbolethLogin->login());
+});
 
-    // Force user to relogin to see changes to him/her identities
+Flight::route('/auth/x509', function() {
     session_start();
-    session_destroy();
+    global $locator;
+    $x509Login = new \RAP\X509Login($locator);
+    $x509Login->login();
+});
 
-    global $BASE_PATH, $VERSION;
-    Flight::render('join-success.php', array('title' => 'Success - RAP Join Request',
-        'version' => $VERSION,
-        'basePath' => $BASE_PATH));
+Flight::route('/local', function() {
+    global $AUTHENTICATION_METHODS;
+    sendAuthRedirect($AUTHENTICATION_METHODS['LocalIdP']['url']);
 });
 
 /**
- * Render the page for selecting th correct name and username from candidates
+ * Render the page for selecting the correct name and username from candidates
  * list during a X.509 registration.
  */
 Flight::route('GET /x509-name-surname', function() {
 
-    startSession();
-    global $session, $BASE_PATH, $VERSION;
+    session_start();
+    global $locator, $BASE_PATH, $VERSION;
+    $session = $locator->getSession();
 
-    if ($session->x509DataToRegister !== null && $session->x509DataToRegister->name === null) {
+    if ($session->getX509DataToRegister() !== null && $session->getX509DataToRegister()->name === null) {
         Flight::render('x509-name-surname.php', array('title' => 'Select name and surname',
             'version' => $VERSION,
-            'fullName' => $session->x509DataToRegister->fullName,
-            'candidateNames' => $session->x509DataToRegister->candidateNames));
+            'fullName' => $session->getX509DataToRegister()->fullName,
+            'candidateNames' => $session->getX509DataToRegister()->candidateNames));
     } else {
         // Redirect to index
         header("Location: " . $BASE_PATH);
@@ -165,60 +248,133 @@ Flight::route('GET /x509-name-surname', function() {
 
 /**
  * Complete the X.509 registration selecting the correct name and surname specified
- * by the user.
+ * by the user (special case of composite names).
  */
 Flight::route('POST /submit-x509-name', function() {
 
+    session_start();
+
     $selectedNameIndex = Flight::request()->data['selected-name'];
-    
-    error_log('index=' . $selectedNameIndex);
-    
-    startSession();
-    global $session, $BASE_PATH;
-    
-    if ($session->x509DataToRegister !== null) {
-        $session->x509DataToRegister->selectCandidateName($selectedNameIndex);
-        $session->userToLogin = $session->x509DataToRegister->toUser();
-        $session->x509DataToRegister = null;
-        $session->save();
-        header("Location: " . $BASE_PATH . '/tou-check');
-        die();
+
+    global $locator;
+    $session = $locator->getSession();
+
+    if ($session->getX509DataToRegister() !== null) {
+        $session->getX509DataToRegister()->selectCandidateName($selectedNameIndex);
+        $loginHandler = new \RAP\X509Login($locator);
+        $redirect = $loginHandler->afterNameSurnameSelection($session->getX509DataToRegister());
+        Flight::redirect($redirect);
     } else {
         die('X.509 data not returned');
     }
 });
 
+/**
+ * Display Term of Use acceptance page.
+ */
 Flight::route('GET /tou-check', function() {
 
-    startSession();
-    global $session, $BASE_PATH, $VERSION;
+    session_start();
+    global $locator;
 
-    if ($session->userToLogin === null) {
+    if ($locator->getSession()->getUser() === null) {
         die("User data not retrieved.");
     } else {
         Flight::render('tou-check.php', array('title' => 'Terms of Use acceptance',
-            'user' => $session->userToLogin,
-            'version' => $VERSION,
-            'registration_url' => $BASE_PATH . '/register'));
+            'user' => $locator->getSession()->getUser(),
+            'version' => $locator->getVersion(),
+            'registration_url' => $locator->getBasePath() . '/register'));
     }
 });
 
+/**
+ * Stores the user data into the database after he/she accepted the Terms of Use.
+ */
 Flight::route('GET /register', function() {
 
-    startSession();
-    global $session, $userHandler, $auditLog, $callbackHandler;
+    session_start();
+    global $locator;
 
-    if ($session->userToLogin === null) {
+    $user = $locator->getSession()->getUser();
+
+    if ($user === null) {
         die("User data not retrieved.");
     } else {
+        $locator->getUserHandler()->saveUser($user);
+
+        $loginHandler = new \RAP\LoginHandler($locator, $user->identities[0]->type);
+        Flight::redirect($loginHandler->getAfterLoginRedirect($user));
+    }
+});
+
+/**
+ * Shows Account Management page.
+ */
+Flight::route('GET /account', function () {
+
+    session_start();
+    global $locator;
+
+    $user = $locator->getSession()->getUser();
+    if ($user === null) {
+        Flight::redirect('/');
+    } else {
+        $admin = $locator->getUserDAO()->isAdmin($user->id);
+        Flight::render('account-management.php', array('title' => 'RAP Account Management',
+            'version' => $locator->getVersion(), 'session' => $locator->getSession(),
+            'admin' => $admin,
+            'contextRoot' => $locator->config->contextRoot));
+    }
+});
+
+Flight::route('GET /token-issuer', function () {
+
+    session_start();
+
+    if (empty($_SESSION['csrf_token'])) {
+        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+    }
+    $csrfToken = $_SESSION['csrf_token'];
+
+    global $locator;
+
+    $user = $locator->getSession()->getUser();
+    $config = $locator->config->tokenIssuer;
+
+    if ($user === null) {
+        Flight::redirect('/');
+    } else {
+        $admin = $locator->getUserDAO()->isAdmin($user->id);
+        Flight::render('token-issuer.php', array('title' => 'RAP Token Issuer',
+            'version' => $locator->getVersion(), 'session' => $locator->getSession(),
+            'config' => $config, 'csrfToken' => $csrfToken,
+            'contextRoot' => $locator->config->contextRoot));
+    }
+});
 
-        $user = $session->userToLogin;
-        $userHandler->saveUser($user);
+Flight::route('POST /token-issuer', function () {
 
-        $session->userToLogin = null;
-        $session->save();
+    session_start();
+    global $locator;
+
+    if (empty($_POST['csrf_token']) || !(hash_equals($_SESSION['csrf_token'], $_POST['csrf_token']))) {
+        throw new \RAP\UnauthorizedException("Invalid CSRF token");
+    }
+    if ($locator->getSession()->getUser() === null) {
+        throw new \RAP\UnauthorizedException("You must be registered to perform this action");
+    }
 
-        $auditLog->info("LOGIN," . $user->identities[0]->type . "," . $user->id);
-        $callbackHandler->manageLoginRedirect($user, $session);
+    $postData = Flight::request()->data;
+    if (!isset($postData['lifespan']) || !isset($postData['audit'])) {
+        throw new \RAP\BadRequestException("Missing form parameter");
     }
+
+    $tokenBuilder = $locator->getTokenBuilder();
+    $token = $tokenBuilder->generateNewToken($postData['lifespan'], $postData['audit']);
+
+    header('Content-Type: text/plain');
+    header("Content-disposition: attachment; filename=\"token.txt\"");
+    echo $token;
 });
+
+include 'admin.php';
diff --git a/include/gui-backend.php b/include/gui-backend.php
index 44e3eda9384dd4bb5aa03b63734d7cdda528a41d..9909263c61c04f2f25e5a63643a67a31b4cdbaea 100644
--- a/include/gui-backend.php
+++ b/include/gui-backend.php
@@ -7,50 +7,15 @@
 
 function checkSession() {
 
-    startSession();
+    session_start();
 
-    global $session;
-    if ($session->user === null) {
+    global $locator;
+    if ($locator->getSession()->getUser() === null) {
         http_response_code(401);
         die("You must be registered to perform this action");
     }
 }
 
-/**
- * This is called when an user search for other users into the join modal dialog.
- */
-Flight::route('GET /user', function() {
-
-    checkSession();
-    global $session;
-
-    $searchText = Flight::request()->query['search'];
-    $session->searchUser($searchText);
-
-    $jsRes = [];
-    foreach ($session->userSearchResults as $searchResult) {
-        array_push($jsRes, $searchResult->userDisplayText);
-    }
-
-    echo json_encode($jsRes);
-});
-
-Flight::route('POST /join', function() {
-
-    checkSession();
-    global $session, $dao, $mailSender;
-
-    $selectedUserIndex = Flight::request()->data['selectedUserIndex'];
-    $selectedSearchResult = $session->userSearchResults[$selectedUserIndex];
-    $targetUserId = $selectedSearchResult->getUser()->id;
-
-    $token = RAP\Util::createNewToken();
-    $dao->createJoinRequest($token, $session->user->id, $targetUserId);
-    $mailSender->sendJoinEmail($selectedSearchResult->getUser(), $session->user, $token);
-
-    echo $selectedSearchResult->userDisplayText;
-});
-
 /**
  * Set the primary identity based on the index of the identity clicked by the
  * user on the Account Management page. We MUST use the index and not directly
@@ -60,15 +25,17 @@ Flight::route('POST /join', function() {
 Flight::route('POST /primary-identity', function() {
 
     checkSession();
-    global $session, $dao;
+    global $locator;
+
+    $session = $locator->getSession();
 
     $identityIndex = intval(Flight::request()->data['index']);
-    $identityId = $session->user->identities[$identityIndex]->id;
+    $identityId = $session->getUser()->identities[$identityIndex]->id;
 
-    $dao->setPrimaryIdentity($session->user->id, $identityId);
+    $locator->getUserDAO()->setPrimaryIdentity($session->getUser()->id, $identityId);
     $session->updatePrimaryIdentity($identityId);
 
     // Following variable is used to render user-data
-    $user = $session->user;
+    $user = $session->getUser();
     include 'user-data.php';
 });
diff --git a/include/header.php b/include/header.php
index 267bb9c942bcf496ee4e529208a2aae0d9e65ed8..2935bed635faed050cec2fdce1e1245c32f13970 100644
--- a/include/header.php
+++ b/include/header.php
@@ -17,7 +17,11 @@
                 Image Credits & Copyright: Colombari/E.Recurt
             </div>
             <div class="page-title-wrapper">
-                <h1 class="text-center">Remote Authentication Portal</h1>
+                <h1 class="text-center">Remote Authentication Portal
+                    <span class="circle-wrapper">
+                        <div class="circle">beta<br/>version!</div>
+                    </span>
+                </h1>
             </div>
         </header>
         <div class="container">
diff --git a/include/init.php b/include/init.php
index 67fa2ba5683762c9f9005fa1797331d2c873501d..de9808cdb95cc9798dc81d7ee64f9c945cc11aea 100644
--- a/include/init.php
+++ b/include/init.php
@@ -24,47 +24,33 @@
 
 /**
  * Initialization file called by all the other pages.
- * Probably some global variables should be removed from here.
  */
 define('ROOT', dirname(dirname(__FILE__)));
 
 // Defining autoload for RAP classes
-spl_autoload_register(function ($class_name) {
+spl_autoload_register(function ($class) {
     $prefix = "RAP\\";
     $len = strlen($prefix);
-    if (strncmp($prefix, $class_name, $len) === 0) {
-        $classpath = ROOT . '/classes/' . substr($class_name, $len, strlen($class_name) - $len) . '.php';
-        require $classpath;
+
+    if (strncmp($prefix, $class, $len) === 0) {
+
+        $fileName = substr($class, $len, strlen($class) - $len);
+
+        $classDirectories = ['/', '/login/', '/exceptions/', '/datalayer/', '/model/'];
+        foreach ($classDirectories as $directory) {
+            $classpath = ROOT . '/classes' . $directory . $fileName . '.php';
+            if (file_exists($classpath)) {
+                require_once $classpath;
+            }
+        }
     }
 });
 
 // Loading dependecy classes
 include ROOT . '/vendor/autoload.php';
-// Loading configuration
-include ROOT . '/config.php';
 
-// Setup logging
-// Monolog require timezone to be set
-date_default_timezone_set("Europe/Rome");
-$log = new Monolog\Logger('mainLogger');
-$log->pushHandler(new Monolog\Handler\StreamHandler($LOG_PATH, $LOG_LEVEL));
-$auditLog = new Monolog\Logger('auditLogger');
-$auditLog->pushHandler(new Monolog\Handler\StreamHandler($AUDIT_LOG_PATH, $LOG_LEVEL));
-
-switch ($DATABASE['dbtype']) {
-    case 'MySQL':
-        $dao = new RAP\MySQLDAO($DATABASE);
-        break;
-    default:
-        throw new Exception($DATABASE['dbtype'] . ' not supported yet');
-}
-
-$callbackHandler = new RAP\CallbackHandler($dao, $BASE_PATH, $CALLBACKS);
-$userHandler = new RAP\UserHandler($dao, $GROUPER);
-$mailSender = new RAP\MailSender($_SERVER['HTTP_HOST'], $BASE_PATH);
+// Loading configuration
+$config = json_decode(file_get_contents(ROOT . '/config.json'));
 
-function startSession() {
-    session_start();
-    global $session, $dao;
-    $session = RAP\SessionData::get($dao);
-}
+// Generating locator (global registry)
+$locator = new \RAP\Locator($config);
diff --git a/include/rest-web-service.php b/include/rest-web-service.php
index b0ffc190795cd8c4b0d6c1cd9b7d6ff73b624c5e..ac213cf92bb7716bd1bcd1f11508744da3f69d5b 100644
--- a/include/rest-web-service.php
+++ b/include/rest-web-service.php
@@ -2,52 +2,23 @@
 
 /**
  * REST Web Service using http://flightphp.com/
- * This Web Service should be called only by authorized applications external to
- * RAP, so this directory should be password protected.
- * 
- * Apache configuration:
- * <Location "/rap-ia2/ws">
- *     AuthType basic
- *     AuthName RAP
- *     AuthUserFile rap-service-passwd
- *     Require valid-user
- * </Location>
  */
 //
 $WS_PREFIX = '/ws';
 
-/**
- * Retrieve user information from login token.
- */
-Flight::route('GET ' . $WS_PREFIX . '/user-info', function() {
-
-    global $dao;
-
-    $token = Flight::request()->query['token'];
-    $userData = $dao->findLoginToken($token);
-
-    if (is_null($userData)) {
-        http_response_code(404);
-        die("Token not found");
-    }
-
-    $dao->deleteLoginToken($token);
-
-    header('Content-Type: text/plain');
-    echo $userData;
-});
-
 /**
  * Retrieve user information from user ID.
  */
 Flight::route('GET ' . $WS_PREFIX . '/user/@userId', function($userId) {
 
-    global $dao;
+    global $locator;
 
-    $user = $dao->findUserById($userId);
+    $token = $locator->getTokenChecker()->validateToken();
+    $locator->getTokenChecker()->checkScope($token, 'read:rap');
+
+    $user = $locator->getUserDAO()->findUserById($userId);
     if ($user !== null) {
-        header('Content-Type: application/json');
-        echo json_encode($user);
+        Flight::json($user);
     } else {
         http_response_code(404);
         die("User not found");
@@ -59,19 +30,37 @@ Flight::route('GET ' . $WS_PREFIX . '/user/@userId', function($userId) {
  */
 Flight::route('GET ' . $WS_PREFIX . '/user', function() {
 
-    global $dao;
+    global $locator;
+
+    $token = $locator->getTokenChecker()->validateToken();
+    $locator->getTokenChecker()->checkScope($token, 'read:rap');
 
     $searchText = Flight::request()->query['search'];
-    $users = $dao->searchUser($searchText);
-    echo json_encode($users);
+    if ($searchText !== null) {
+        $users = $locator->getUserDAO()->searchUser($searchText);
+    } else {
+        $identifiers = Flight::request()->query['identifiers'];
+        if ($identifiers === null) {
+            throw new \RAP\BadRequestException("Missing identifiers parameters");
+        }
+        $identifiers = explode(',', $identifiers);
+        $users = $locator->getUserDAO()->getUsers($identifiers);
+    }
+
+    Flight::json($users);
 });
 
 /**
  * Create new user from identity data. Return the new user encoded in JSON.
+ * This can be used to automatically import users without they explicitly
+ * register (this is done for INAF eduGAIN users reading directly from LDAP).
  */
 Flight::route('POST ' . $WS_PREFIX . '/user', function() {
 
-    global $userHandler;
+    global $locator;
+
+    $token = $locator->getTokenChecker()->validateToken();
+    $locator->getTokenChecker()->checkScope($token, 'write:rap');
 
     $postData = Flight::request()->data;
 
@@ -96,25 +85,7 @@ Flight::route('POST ' . $WS_PREFIX . '/user', function() {
 
     $user->addIdentity($identity);
 
-    $userHandler->saveUser($user);
-
-    echo json_encode($user);
-});
-
-/**
- * Performs a join.
- */
-Flight::route('POST ' . $WS_PREFIX . '/join', function() {
-
-    global $userHandler;
-    $postData = Flight::request()->data;
-
-    $userHandler->joinUsers($postData['user1'], $postData['user2']);
-
-    // if the join has success, returns the remaining user id
-    echo $postData['user1'];
-});
+    $locator->getUserHandler()->saveUser($user);
 
-Flight::route('GET ' . $WS_PREFIX . '/test', function() {
-    
+    Flight::json($user);
 });
diff --git a/index.php b/index.php
index 754f719c6ba324aeb4dd06185284f4094c3d769c..5fd9f6db83a294a0fec9534fe1ad28f1439779be 100644
--- a/index.php
+++ b/index.php
@@ -28,5 +28,27 @@ include './include/front-controller.php';
 include './include/gui-backend.php';
 include './include/rest-web-service.php';
 
+// Error handling
+Flight::map('error', function($ex) {
+    if ($ex instanceof \RAP\BadRequestException) {
+        http_response_code(400);
+        echo "Bad request: " . $ex->message;
+    } else if ($ex instanceof \RAP\UnauthorizedException) {
+        http_response_code(401);
+        echo "Unauthorized: " . $ex->message;
+    } else if ($ex instanceof \Exception) {
+        http_response_code(500);
+        if ($ex->getMessage() !== null) {
+            echo $ex->getMessage();
+        } else {
+            echo $ex->getTraceAsString();
+        }
+    } else {
+        http_response_code(500);
+        throw $ex;
+    }
+});
+
 // Starting Flight framework
 Flight::start();
+
diff --git a/js/admin.js b/js/admin.js
new file mode 100644
index 0000000000000000000000000000000000000000..d07d56d68abcf2289a87b6b2596b925b886032fa
--- /dev/null
+++ b/js/admin.js
@@ -0,0 +1,162 @@
+(function () {
+
+    var AUTH_METHODS = ['eduGAIN', 'Google', 'Facebook', 'LinkedIn', 'X.509', 'LocalIdP'];
+
+    var vm = new Vue({
+        el: '#admin-vue',
+        data: {
+            oauth2Clients: [],
+            oauth2ClientToDelete: null
+        },
+        methods: {
+            addNewOAuth2Client: function () {
+                this.$data.oauth2Clients.push(getNewClient());
+            },
+            editOAuth2Client: function (client) {
+                client.edit = true;
+                vm.$forceUpdate();
+            },
+            getAuthMethodsString: function (authMethods) {
+                var selectedValues = [];
+                for (var i = 0; i < AUTH_METHODS.length; i++) {
+                    var method = AUTH_METHODS[i];
+                    if (authMethods[method] === true) {
+                        selectedValues.push(method);
+                    }
+                }
+                return selectedValues.join(', ');
+            },
+            saveOAuth2Client: function (client, index) {
+                if (client.id === null) {
+                    createOAuth2Client(client, index);
+                } else {
+                    updateOAuth2Client(client, index);
+                }
+            },
+            askConfirmDeleteOAuth2Client: function (client, index) {
+                vm.$data.oauth2ClientToDelete = index;
+                if (client.id === null) {
+                    deleteOAuth2Client();
+                } else {
+                    $('#client-to-delete').text(client.title);
+                    $('#confirm-delete-client-modal').modal('show');
+                }
+            }
+        }
+    });
+
+    function getNewClient() {
+        var client = {
+            id: null,
+            title: null,
+            icon: null,
+            client: null,
+            secret: null,
+            redirectUrl: null,
+            scope: null,
+            homePage: null,
+            showInHome: false,
+            authMethods: {},
+            edit: true
+        };
+        for (var i = 0; i < AUTH_METHODS.length; i++) {
+            client.authMethods[AUTH_METHODS[i]] = true;
+        }
+        return client;
+    }
+
+    /* Converts the model received from the server into a model
+     * useful for the Vue manipulation */
+    function getJsModel(client) {
+        var jsAuthMethods = {};
+        for (var i = 0; i < AUTH_METHODS.length; i++) {
+            var method = AUTH_METHODS[i];
+            jsAuthMethods[method] = client.authMethods.includes(method);
+        }
+        client.authMethods = jsAuthMethods;
+        client.edit = false;
+        return client;
+    }
+
+    /* Converts the model manipulated by Vue in the model expected by the back-end */
+    function getBackendModel(client) {
+        var client = Vue.util.extend({}, client);
+        var authMethods = [];
+        for (var i = 0; i < AUTH_METHODS.length; i++) {
+            var method = AUTH_METHODS[i];
+            if (client.authMethods[method] === true) {
+                authMethods.push(method);
+            }
+        }
+        client.authMethods = authMethods;
+        delete client.edit;
+        return client;
+    }
+
+    function createOAuth2Client(client, index) {
+        showWaiting();
+        $.ajax({
+            url: 'admin/oauth2_clients',
+            method: 'POST',
+            dataType: 'json',
+            contentType: 'application/json',
+            data: JSON.stringify(getBackendModel(client))
+        }).then(function (data) {
+            vm.$data.oauth2Clients[index] = getJsModel(data);
+            vm.$forceUpdate();
+            hideWaiting();
+        });
+    }
+
+    function updateOAuth2Client(client, index) {
+        showWaiting();
+        $.ajax({
+            url: 'admin/oauth2_clients',
+            method: 'PUT',
+            dataType: 'json',
+            contentType: 'application/json',
+            data: JSON.stringify(getBackendModel(client))
+        }).then(function (data) {
+            vm.$data.oauth2Clients[index] = getJsModel(data);
+            vm.$forceUpdate();
+            hideWaiting();
+        });
+    }
+
+
+    function deleteOAuth2Client() {
+        var client = vm.$data.oauth2Clients[vm.$data.oauth2ClientToDelete];
+        if (client.id === null) {
+            vm.$data.oauth2Clients.splice(vm.$data.oauth2ClientToDelete, 1);
+            vm.$forceUpdate();
+        } else {
+            showWaiting();
+            $.ajax({
+                url: 'admin/oauth2_clients/' + client.id,
+                method: 'DELETE'
+            }).then(function () {
+                vm.$data.oauth2Clients.splice(vm.$data.oauth2ClientToDelete, 1);
+                $('#confirm-delete-client-modal').modal('hide');
+                vm.$forceUpdate();
+                hideWaiting();
+            });
+        }
+    }
+    $(document).on('click', '#confirm-delete-client', deleteOAuth2Client);
+
+    showWaiting();
+
+    $.ajax({
+        url: 'admin/oauth2_clients',
+        method: 'GET',
+        dataType: 'json'
+    }).then(function (data) {
+        var clients = [];
+        for (var i = 0; i < data.length; i++) {
+            clients.push(getJsModel(data[i]));
+        }
+        vm.$data.oauth2Clients = clients;
+        hideWaiting();
+    });
+
+})();
\ No newline at end of file
diff --git a/js/index.js b/js/index.js
index fca834170570f0ed2ba96bf3b0f695e345a3ed2d..78e521aeeba5d0a83f871c3e23452273652fd5d5 100644
--- a/js/index.js
+++ b/js/index.js
@@ -4,50 +4,6 @@
 // IIFE for keeping private functions and variables inside.
 (function ($) {
 
-    /**
-     * Function factory used to generate function to be executed at timeout (see below)
-     * @param {string} searchText
-     * @returns {Function}
-     */
-    function searchUserFactory(searchText) {
-        return function () {
-            $.get('user?search=' + searchText, function (response) {
-                var users = JSON.parse(response);
-
-                // Display the selector only if we have some results
-                $('#user-selector-group').toggleClass('hide', users.length === 0);
-
-                // Fill the user selector
-                $userSelector = $('#user-selector-group select');
-                $userSelector.empty();
-                for (var i = 0; i < users.length; i++) {
-                    $userSelector.append('<option value="' + i + '">' + users[i] + '</option>');
-                }
-                
-                updateJoinButtonStatus();
-            });
-        };
-    }
-
-    /**
-     * Function associated to the "Send join request" button on the join modal
-     * dialog.
-     */
-    function sendJoinRequest() {
-
-        $userSelector = $('#user-selector-group select');
-        var selectedUserIndex = $userSelector.val();
-
-        if (selectedUserIndex !== null) {
-            $.post('join', {selectedUserIndex: selectedUserIndex}, function (response) {
-                $('#search-user-modal').modal('hide');
-                $infoAlert = $('#info-message-alert');
-                $infoAlert.find('.info-message').text("An e-mail has been sent to " + response + " primary e-mail address.");
-                $infoAlert.removeClass('hide');
-            });
-        }
-    }
-
     /**
      * Select the primary identity from the available identities of the user.
      * @param {int} index
@@ -71,40 +27,12 @@
      */
     function loadTooltips() {
         $('.primary-identity-icon').tooltip();
-    }
-
-    /**
-     * Enable or disable the join button if there are selected users or not.
-     */
-    function updateJoinButtonStatus() {
-        var selectedUserIndex = $('#user-selector-group select').val();
-        $('#send-join-request-btn').prop("disabled", selectedUserIndex === null);
+        $('#join-btn').tooltip();
+        $('#token-issuer-btn').tooltip();
     }
 
     // When the document is loaded
     $(document).ready(function () {
-
-        // Add keyup event handler on user search input text
-        var timeoutId = 0;
-        $(document).on('keyup', '#user-search-text', function (event) {
-            clearTimeout(timeoutId);
-            var searchUser = searchUserFactory($(event.target).val());
-            // wait 500 ms without typing before doing the AJAX call
-            timeoutId = setTimeout(searchUser, 500);
-        });
-
-        // Add click event handler to join request button
-        $(document).on('click', '#send-join-request-btn', sendJoinRequest);
-
-        // Add event handler for closing the info alert message.
-        // This is used instead of data-dismiss="alert" in order to maintain
-        // the alert inside the DOM and be able to show it again if necessary.
-        $(document).on('click', '#info-message-alert .close', function () {
-            $('#info-message-alert').addClass('hide');
-        });
-
-        $('#search-user-modal').on('shown.bs.modal', updateJoinButtonStatus);
-
         loadTooltips();
     });
 
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b5124ad913b0d19879339914a749e8c0c2ab487b
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,7 @@
+<phpunit colors="true">
+    <filter>
+        <whitelist processUncoveredFilesFromWhitelist="true">
+            <directory suffix=".php">./classes/</directory>
+        </whitelist>
+    </filter>
+</phpunit>
diff --git a/sql/setup-database.sql b/sql/setup-database.sql
index d5b4328d9e15973e4f0dbdd1a026cfca934dd987..2d46eaa0ca2af1912ff5edee9eabc30bfee173d4 100644
--- a/sql/setup-database.sql
+++ b/sql/setup-database.sql
@@ -1,3 +1,31 @@
+CREATE TABLE `oauth2_client` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `title` varchar(255) NOT NULL,
+  `icon` varchar(255) DEFAULT NULL,
+  `client` varchar(255) NOT NULL,
+  `secret` varchar(255) NOT NULL,
+  `redirect_url` text NOT NULL,
+  `scope` varchar(255) DEFAULT NULL,
+  `home_page` varchar(255) DEFAULT NULL,
+  `show_in_home` tinyint(1) DEFAULT 0,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE TABLE `oauth2_client_auth_methods` (
+  `client_id` int NOT NULL,
+  `auth_method` varchar(50) NOT NULL,
+  PRIMARY KEY (`client_id`, `auth_method`),
+  FOREIGN KEY (`client_id`) REFERENCES `oauth2_client`(`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE TABLE `oauth2_client_scope_audience_mapping` (
+  `client_id` int NOT NULL,
+  `scope` varchar(255) NOT NULL,
+  `audience` text NOT NULL,
+  PRIMARY KEY (`client_id`, `scope`),
+  FOREIGN KEY (`client_id`) REFERENCES `oauth2_client`(`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
 CREATE TABLE `user` (
   `id` bigint(20) NOT NULL AUTO_INCREMENT,
   `primary_identity` bigint(20) DEFAULT NULL,
@@ -23,23 +51,42 @@ SET FOREIGN_KEY_CHECKS=0;
 ALTER TABLE `user` ADD FOREIGN KEY (`primary_identity`) REFERENCES `identity`(`id`);
 SET FOREIGN_KEY_CHECKS=1;
 
-CREATE TABLE `login_token` (
+CREATE TABLE `access_token` (
   `id` bigint(20) NOT NULL AUTO_INCREMENT,
-  `token` varchar(255) NOT NULL,
-  `user_id` text,
-  `creation_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `user_id` varchar(255) NOT NULL,
+  `code_hash` varchar(255) DEFAULT NULL,
+  `creation_time` bigint(20) NOT NULL,
+  `expiration_time` bigint(20) DEFAULT NULL,
+  `redirect_uri` text DEFAULT NULL,
+  `client_id` varchar(255) DEFAULT NULL,
+  `scope` text DEFAULT NULL,
   PRIMARY KEY (`id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 
-CREATE TABLE `join_request` (
+CREATE TABLE `refresh_token` (
   `id` bigint(20) NOT NULL AUTO_INCREMENT,
-  `token` varchar(255) NOT NULL,
-  `applicant_user_id` bigint(20) NOT NULL,
-  `target_user_id` bigint(20) NOT NULL,
-  `creation_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
-  PRIMARY KEY (`id`),
-  FOREIGN KEY (`applicant_user_id`) REFERENCES `user`(`id`),
-  FOREIGN KEY (`target_user_id`) REFERENCES `user`(`id`)
+  `token_hash` varchar(255) NOT NULL,
+  `user_id` text NOT NULL,
+  `creation_time` BIGINT NOT NULL,
+  `expiration_time` BIGINT NOT NULL,
+  `client_id` varchar(255) NOT NULL,
+  `scope` text,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE TABLE `rsa_keypairs` (
+  `id` varchar(50) NOT NULL,
+  `public_key` text,
+  `private_key` text,
+  `alg` varchar(255),
+  `creation_time` BIGINT NOT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE TABLE `rap_permissions` (
+  `user_id` bigint NOT NULL,
+  `permission` varchar(255) NOT NULL,
+  PRIMARY KEY (`user_id`, `permission`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 
 CREATE EVENT login_tokens_cleanup
diff --git a/tests/IdTokenBuilderTest.php b/tests/IdTokenBuilderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..2328f2c7bcc72080a617297fb20d01fea8d95c70
--- /dev/null
+++ b/tests/IdTokenBuilderTest.php
@@ -0,0 +1,56 @@
+<?php
+
+use PHPUnit\Framework\TestCase;
+use \Firebase\JWT\JWT;
+
+final class TokenBuilderTest extends TestCase {
+
+    public function testJWTCreation() {
+
+        $jwksDAOStub = $this->createMock(\RAP\JWKSDAO::class);
+
+        $locatorStub = $this->createMock(\RAP\Locator::class);
+        $locatorStub->method('getJWKSDAO')->willReturn($jwksDAOStub);
+
+        $jwksHandler = new \RAP\JWKSHandler($locatorStub);
+        $keyPair = $jwksHandler->generateKeyPair();
+
+        $jwksDAOStub->method('getNewestKeyPair')->willReturn($keyPair);
+
+        $user = new \RAP\User();
+        $user->id = "user_id";
+        $identity = new \RAP\Identity(\RAP\Identity::EDU_GAIN);
+        $identity->email = "name@inaf.it";
+        $identity->name = "Name";
+        $identity->surname = "Surname";
+        $identity->primary = true;
+        $identity->institution = "INAF";
+        $user->addIdentity($identity);
+
+        $daoStub = $this->createMock(\RAP\UserDAO::class);
+        $locatorStub->method('getUserDAO')->willReturn($daoStub);
+        $daoStub->method('findUserById')->willReturn($user);
+
+        $locatorStub->config = json_decode('{"jwtIssuer": "issuer"}');
+
+        $accessToken = new \RAP\AccessToken();
+        $accessToken->token = "ttt";
+        $accessToken->scope = ["email", "profile"];
+        $accessToken->userId = "user_id";
+
+        $tokenBuilder = new \RAP\TokenBuilder($locatorStub);
+        $jwt = $tokenBuilder->getIdToken($accessToken);
+
+        $this->assertNotNull($jwt);
+
+        $payload = JWT::decode($jwt, $keyPair->publicKey, [$keyPair->alg]);
+
+        $this->assertEquals("issuer", $payload->iss);
+        $this->assertEquals($user->id, $payload->sub);
+        $this->assertEquals($user->getCompleteName(), $payload->name);
+        $this->assertEquals($identity->name, $payload->given_name);
+        $this->assertEquals($identity->surname, $payload->family_name);
+        $this->assertEquals($identity->institution, $payload->org);
+    }
+
+}
diff --git a/tests/JWKSHandlerTest.php b/tests/JWKSHandlerTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..7901dc7645ec6c1cefdac7658e3b9fe89750ef3a
--- /dev/null
+++ b/tests/JWKSHandlerTest.php
@@ -0,0 +1,26 @@
+<?php
+
+use PHPUnit\Framework\TestCase;
+
+final class JWKSHandlerTest extends TestCase {
+
+    public function testKeyPairCreation(): void {
+
+        $daoStub = $this->createMock(\RAP\JWKSDAO::class);
+
+        $locatorStub = $this->createMock(\RAP\Locator::class);
+        $locatorStub->method('getJWKSDAO')->willReturn($daoStub);
+
+        $JWKSHandler = new \RAP\JWKSHandler($locatorStub);
+
+        $daoStub->expects($this->once())
+                ->method('insertRSAKeyPair')->with($this->anything());
+
+        $result = $JWKSHandler->generateKeyPair();
+
+        $this->assertNotNull($result->keyId);
+        $this->assertNotNull($result->privateKey);
+        $this->assertNotNull($result->publicKey);
+    }
+
+}
diff --git a/tests/OAuth2RequestHandlerTest.php b/tests/OAuth2RequestHandlerTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e24171a449a595b89b83a976c6197098ecb7d90e
--- /dev/null
+++ b/tests/OAuth2RequestHandlerTest.php
@@ -0,0 +1,106 @@
+<?php
+
+use PHPUnit\Framework\TestCase;
+
+final class OAuth2RequestHandlerTest extends TestCase {
+
+    public function testBadRequestExceptionIfMissingClientId(): void {
+
+        $this->expectException(\RAP\BadRequestException::class);
+
+        $params = [
+            "client_id" => null
+        ];
+
+        $locatorStub = $this->createMock(\RAP\Locator::class);
+
+        $requestHandler = new \RAP\OAuth2RequestHandler($locatorStub);
+        $requestHandler->handleAuthorizeRequest($params);
+    }
+
+    public function testInvalidClientRedirectURI(): void {
+
+        $this->expectException(\RAP\BadRequestException::class);
+
+        $params = [
+            "client_id" => "client_id",
+            "redirect_uri" => "redirect_uri",
+            "state" => "state",
+            "alg" => null,
+            "scope" => "email%20profile"
+        ];
+
+        $daoStub = $this->createMock(\RAP\OAuth2ClientDAO::class);
+        $daoStub->method('getOAuth2ClientByClientId')->willReturn(new \RAP\OAuth2Client());
+
+        $locatorStub = $this->createMock(\RAP\Locator::class);
+        $locatorStub->method('getOAuth2ClientDAO')->willReturn($daoStub);
+
+        $requestHandler = new \RAP\OAuth2RequestHandler($locatorStub);
+        $requestHandler->handleAuthorizeRequest($params);
+    }
+
+    public function testExecuteOAuthStateFlow(): void {
+
+        $params = [
+            "client_id" => "client_id",
+            "redirect_uri" => "redirect_uri",
+            "state" => "state",
+            "alg" => null,
+            "nonce" => null,
+            "scope" => "email%20profile"
+        ];
+
+        $daoStub = $this->createMock(\RAP\OAuth2ClientDAO::class);
+        $client = new \RAP\OAuth2Client();
+        $client->redirectUrl = "redirect_uri";
+        $daoStub->method('getOAuth2ClientByClientId')->willReturn($client);
+
+        $sessionStub = $this->createMock(\RAP\SessionData::class);
+
+        $locatorStub = $this->createMock(\RAP\Locator::class);
+        $locatorStub->method('getOAuth2ClientDAO')->willReturn($daoStub);
+        $locatorStub->method('getSession')->willReturn($sessionStub);
+
+        $sessionStub->expects($this->once())
+                ->method('setOAuth2RequestData')->with($this->anything());
+
+        $requestHandler = new \RAP\OAuth2RequestHandler($locatorStub);
+        $requestHandler->handleAuthorizeRequest($params);
+    }
+
+    public function testHandleCheckTokenRequest(): void {
+
+        $accessToken = new \RAP\AccessToken();
+        $accessToken->clientId = 'my-client';
+        $accessToken->scope = ['openid', 'email'];
+        $accessToken->userId = '123';
+        $accessToken->expirationTime = time() + 3600;
+
+        $tokenDaoStub = $this->createMock(\RAP\AccessTokenDAO::class);
+        $tokenDaoStub->method('getAccessToken')->willReturn($accessToken);
+
+        $user = new \RAP\User();
+        $user->id = '123';
+        $userDaoStub = $this->createMock(\RAP\UserDAO::class);
+        $userDaoStub->method('findUserById')->willReturn($user);
+
+        $tokenBuilderStub = $this->createMock(\RAP\TokenBuilder::class);
+        $tokenBuilderStub->method('getIdToken')->willReturn('id-token');
+
+        $locatorStub = $this->createMock(\RAP\Locator::class);
+        $locatorStub->method('getAccessTokenDAO')->willReturn($tokenDaoStub);
+        $locatorStub->method('getUserDAO')->willReturn($userDaoStub);
+        $locatorStub->method('getIdTokenBuilder')->willReturn($tokenBuilderStub);
+
+        $requestHandler = new \RAP\OAuth2RequestHandler($locatorStub);
+
+        $result = $requestHandler->handleCheckTokenRequest('abc');
+
+        $this->assertEquals(3600, $result['exp']);
+        $this->assertEquals('123', $result['user_name']);
+        $this->assertEquals('my-client', $result['client_id']);
+        $this->assertEquals('id-token', $result['id_token']);
+    }
+
+}
diff --git a/version.txt b/version.txt
new file mode 100644
index 0000000000000000000000000000000000000000..359a5b952d49f3592571e2af081510656029298e
--- /dev/null
+++ b/version.txt
@@ -0,0 +1 @@
+2.0.0
\ No newline at end of file
diff --git a/views/account-management.php b/views/account-management.php
new file mode 100644
index 0000000000000000000000000000000000000000..39712aba8c5797b11af5818f15d1232a6d90a8d6
--- /dev/null
+++ b/views/account-management.php
@@ -0,0 +1,52 @@
+<?php
+include 'include/header.php';
+?>
+<script src="js/index.js?v=<?php echo $version; ?>"></script>
+
+<div class="row">
+    <div class="col-sm-5 col-xs-12">
+        <div class="panel panel-default" id="panel-identities">
+            <div class="panel-heading">
+                <h3 class="panel-title">Your identities</h3>
+            </div>
+            <div class="panel-body">
+                <?php
+                $user = $session->getUser();
+                include 'include/user-data.php';
+                ?>
+            </div>
+        </div>
+    </div>
+    <div class="col-sm-2">
+        <div class="row">
+            <div class="col-sm-12">
+                <a class="btn btn-success" id="join-btn" href="<?php echo $contextRoot; ?>?action=join" title="Perform an additional login to join your identities" data-toggle="tooltip" data-placement="bottom">
+                    Join with another identity
+                </a>
+            </div>
+        </div>
+        <div class="row">
+            <div class="col-sm-12">
+                <a class="btn btn-default" id="token-issuer-btn" href="<?php echo $contextRoot; ?>/token-issuer" title="Generate tokens for CLI" data-toggle="tooltip" data-placement="bottom">
+                    Token issuer
+                </a>
+            </div>
+        </div>
+        <?php if ($admin) { ?>
+            <div class="row">
+                <div class="col-sm-12">
+                    <a class="btn btn-default" href="<?php echo $contextRoot; ?>/admin" title="Admin panel" data-toggle="tooltip" data-placement="bottom">
+                        <span class="glyphicon glyphicon-wrench"></span>
+                        Admin
+                    </a>
+                </div>
+            </div>
+        <?php } ?>
+    </div>
+    <div class="col-sm-5">
+        <a href="logout" class="btn btn-primary pull-right">Logout</a>
+    </div>
+</div>
+
+<?php
+include 'include/footer.php';
diff --git a/views/admin/index.php b/views/admin/index.php
new file mode 100644
index 0000000000000000000000000000000000000000..e9d7891d34ca9bef678d6ddda5bd7249eca0a209
--- /dev/null
+++ b/views/admin/index.php
@@ -0,0 +1,148 @@
+<?php
+
+include 'include/header.php';
+?>
+
+<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script>
+
+<h1 class="text-center">Admin panel</h1>
+
+<div id="admin-vue">
+    <button class="btn btn-success pull-right" v-on:click="addNewOAuth2Client">
+        <span class="glyphicon glyphicon-plus-sign"></span> Add client
+    </button>
+    <h2>OAuth2/OIDC clients</h2>
+    <br/>
+    <div class="panel panel-default" v-for="(client, index) in oauth2Clients">
+        <div class="panel-heading">
+            <button class="btn btn-danger pull-right" v-on:click="askConfirmDeleteOAuth2Client(client, index)">
+                <span class="glyphicon glyphicon-trash"></span> Delete
+            </button>
+            <button class="btn btn-primary pull-right" v-on:click="editOAuth2Client(client)" v-if="!client.edit">
+                <span class="glyphicon glyphicon-pencil"></span> Edit
+            </button>
+            <button class="btn btn-primary pull-right" v-on:click="saveOAuth2Client(client, index)" v-if="client.edit">
+                <span class="glyphicon glyphicon-floppy-disk"></span> Save
+            </button>
+            <strong>{{client.title}} &nbsp;</strong>
+        </div>
+        <div class="panel-body">
+            <div class="form-horizontal">
+                <div class="form-group">
+                    <label class="col-sm-3 control-label" for="title">Name</label>
+                    <div class="col-sm-9">
+                        <p class="form-control-static" v-if="!client.edit">{{client.title}}</p>
+                        <input type="text" class="form-control" id="title" v-model="client.title" v-if="client.edit" />
+                    </div>
+                </div>
+                <div class="form-group">
+                    <label class="col-sm-3 control-label">Icon</label>
+                    <div class="col-sm-9">
+                        <p class="form-control-static" v-if="!client.edit">{{client.icon}}</p>
+                        <input type="text" class="form-control" id="client_icon" v-model="client.icon" v-if="client.edit" />
+                    </div>
+                </div>
+                <div class="form-group">
+                    <label class="col-sm-3 control-label" for="client_id">Client id</label>
+                    <div class="col-sm-9">
+                        <p class="form-control-static" v-if="!client.edit">{{client.client}}</p>
+                        <input type="text" class="form-control" id="client_id" v-model="client.client" v-if="client.edit" />
+                    </div>
+                </div>
+                <div class="form-group">
+                    <label class="col-sm-3 control-label" for="client_secret">Client Secret</label>
+                    <div class="col-sm-9">
+                        <p class="form-control-static" v-if="!client.edit">{{client.secret}}</p>
+                        <input type="text" class="form-control" id="client_secret" v-model="client.secret" v-if="client.edit" />
+                    </div>
+                </div>
+                <div class="form-group">
+                    <label class="col-sm-3 control-label" for="redirect_url">Redirect URL</label>
+                    <div class="col-sm-9">
+                        <p class="form-control-static" v-if="!client.edit">{{client.redirectUrl}}</p>
+                        <input type="text" class="form-control" id="redirect_url" v-model="client.redirectUrl" v-if="client.edit" />
+                    </div>
+                </div>
+                <div class="form-group">
+                    <label class="col-sm-3 control-label" for="scope">Scope</label>
+                    <div class="col-sm-9">
+                        <p class="form-control-static" v-if="!client.edit">{{client.scope}}</p>
+                        <input type="text" class="form-control" id="scope" v-model="client.scope" v-if="client.edit" />
+                    </div>
+                </div>
+                <div class="form-group">
+                    <label class="col-sm-3 control-label" for="homePage">Home Page</label>
+                    <div class="col-sm-9">
+                        <p class="form-control-static" v-if="!client.edit">{{client.homePage}}</p>
+                        <input type="text" class="form-control" id="homePage" v-model="client.homePage" v-if="client.edit" />
+                    </div>
+                </div>
+                <div class="form-group">
+                    <label class="col-sm-3 control-label" for="showInHome">Show in home</label>
+                    <div class="col-sm-9">
+                        <p class="form-control-static" v-if="!client.edit">
+                            <span class="glyphicon glyphicon-ok" v-if="client.showInHome"></span>
+                        </p>
+                        <div class="checkbox" v-if="client.edit">
+                            <label>
+                                <input type="checkbox" v-model="client.showInHome" />
+                            </label>
+                        </div>
+                    </div>
+                </div>
+                <div class="form-group">
+                    <label class="col-sm-3 control-label" for="authenticationMethods">Authentication methods</label>
+                    <div class="col-sm-9">
+                        <p class="form-control-static" v-if="!client.edit">{{getAuthMethodsString(client.authMethods)}}</p>
+                        <div v-if="client.edit">
+                            <div class="checkbox">
+                                <label>
+                                    <input type="checkbox" v-model="client.authMethods['eduGAIN']"> eduGAIN
+                                </label>
+                                <label>
+                                    <input type="checkbox" v-model="client.authMethods['Google']"> Google
+                                </label>
+                                <label>
+                                    <input type="checkbox" v-model="client.authMethods['Facebook']"> Facebook
+                                </label>
+                                <label>
+                                    <input type="checkbox" v-model="client.authMethods['LinkedIn']"> LinkedIn
+                                </label>
+                                <label>
+                                    <input type="checkbox" v-model="client.authMethods['X.509']"> X.509
+                                </label>
+                                <label>
+                                    <input type="checkbox" v-model="client.authMethods['LocalIdP']"> LocalIdP
+                                </label>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script src="js/admin.js"></script>
+
+<div class="modal fade" tabindex="-1" role="dialog" id="confirm-delete-client-modal">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+                <h4 class="modal-title">Confirm action</h4>
+            </div>
+            <div class="modal-body">
+                <p>Are you sure that you want to delete the client <span id="client-to-delete"></span>?</p>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-danger" id="confirm-delete-client">Delete</button>
+                <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<?php
+
+include 'include/footer.php';
diff --git a/views/confirm-join.php b/views/confirm-join.php
deleted file mode 100644
index 1c22d6a683abd64fedaf0bd8a2b9b5933d8585f7..0000000000000000000000000000000000000000
--- a/views/confirm-join.php
+++ /dev/null
@@ -1,49 +0,0 @@
-<?php
-include 'include/header.php';
-?>
-
-<h1 class="text-center page-title">Confirm join request</h1>
-
-<br/>
-<div class="table-like">
-    <div class="table-row">
-        <div class="table-cell">
-            <div class="panel panel-default">
-                <div class="panel-body">
-                    <?php
-                    $user = $applicantUser;
-                    $readOnly = true;
-                    include 'include/user-data.php';
-                    ?>        
-                </div>
-            </div>
-        </div>
-        <div class="table-cell join-icon-cell text-success">
-            <span class="glyphicon glyphicon-transfer"></span>
-        </div>
-        <div class="table-cell">
-            <div class="panel panel-default">
-                <div class="panel-body">
-                    <?php
-                    $user = $targetUser;
-                    $readOnly = true;
-                    include 'include/user-data.php';
-                    ?>        
-                </div>
-            </div>
-        </div>
-    </div>
-</div>
-
-<div class="row">
-    <div class="col-xs-12 text-center">
-        <p>Pressing the following button the identities listed above will be joined.</p>
-        <form action="confirm-join" method="POST" onsubmit="showWaiting()">
-            <input type="hidden" name="token" value="<?php echo $token; ?>" />
-            <input type="submit" class="btn btn-success btn-lg" value="Join users" />
-        </form>
-    </div>
-</div>
-
-<?php
-include 'include/footer.php';
diff --git a/views/index.php b/views/index.php
deleted file mode 100644
index 774a7d85ab98a2e7bd5abbd58aa59a5bfe56972e..0000000000000000000000000000000000000000
--- a/views/index.php
+++ /dev/null
@@ -1,194 +0,0 @@
-<?php
-include 'include/header.php';
-?>
-<script src="js/index.js?v=<?php echo $version; ?>"></script>
-
-<?php if ($session->user === null) { ?>
-    <div class="row">
-        <div class="col-xs-12">
-            <h1 class="text-center page-title">
-                <?php
-                if ($session->getCallbackLogo() != null) {
-                    ?>
-                    <img class="service-logo" src="service-logos/<?php echo $session->getCallbackLogo(); ?>" alt="" />
-                    <?php
-                }
-                echo $session->getCallbackTitle();
-                ?>
-            </h1>
-        </div>
-    </div>
-
-    <?php
-        if ($session->getCallbackAuth() != null) {
-            $authType = $session->getCallbackAuth();
-        }
-    ?>
-    <div class="row" id="auth-panel">
-        <div class="col-xs-12 text-center">
-            <?php if (isset($auth['eduGAIN']) and 
-                    ( !isset($authType) or in_array('eduGAIN', $authType))) { ?>
-                <div class="home-box">
-                    <div class="img-wrapper">
-                        <a href="edugain?callback=<?php echo $session->getCallbackURL(); ?>">
-                            <img src="img/eduGain-200.png" alt="eduGAIN Logo" />
-                        </a>
-                    </div>
-                    Use the eduGAIN Logo to Login or Register to the RAP facility if you belong to an eduGAIN IdP.
-                </div>
-            <?php } ?>
-            <?php if (isset($auth['Google']) || isset($auth['Facebook']) || isset($auth['LinkedIn'])) { ?>
-                <div class="home-box">
-                    <div class="img-wrapper">
-                        <?php if (isset($auth['Google']) and 
-                                 ( !isset($authType) or 
-                                   in_array('Google', $authType))) { ?>
-                            <a href="google?callback=<?php echo $session->getCallbackURL(); ?>" class="animated pulse">
-                        <?php if ((isset($auth['Facebook']) and
-                                  ( !isset($authType) or 
-                                    in_array('Facebook', $authType))) or
-                                  (isset($auth['LinkedIn']) and
-                                  ( !isset($authType) or 
-                                    in_array('LinkedIn', $authType))))  { ?>
-                                <img src="img/google-60.png" alt="Google Logo" />
-                            </a>
-                        <?php } else {?>
-                                <img src="img/google-200.png" alt="Google Logo" />
-                            </a>
-                        <?php } } ?>
-                        <?php if (isset($auth['Facebook']) and
-                                 ( !isset($authType) or 
-                                   in_array('Facebook', $authType))) { ?>
-                            <a href="facebook?callback=<?php echo $session->getCallbackURL(); ?>">
-                        <?php if ((isset($auth['Google']) and
-                                  ( !isset($authType) or 
-                                    in_array('Google', $authType))) or
-                                  (isset($auth['LinkedIn']) and
-                                  ( !isset($authType) or 
-                                    in_array('LinkedIn', $authType))))  { ?>
-                                <img src="img/facebook-60.png" alt="Facebook Logo" />
-                            </a>
-                        <?php } else {?>
-                                <img src="img/facebook-200.png" alt="Facebook Logo" />
-                            </a>
-                        <?php } } ?>
-                        <?php if (isset($auth['LinkedIn']) and
-                                 ( !isset($authType) or 
-                                   in_array('LinkedIn', $authType))) { ?>
-                            <a href="linkedin?callback=<?php echo $session->getCallbackURL(); ?>">
-                        <?php if ((isset($auth['Facebook']) and
-                                  ( !isset($authType) or 
-                                    in_array('Facebook', $authType))) or
-                                  (isset($auth['Google']) and
-                                  ( !isset($authType) or 
-                                    in_array('Google', $authType))))  { ?>
-                                <img src="img/linkedin-60.png" alt="LinkedIn Logo" />
-                            </a>
-                        <?php } else {?>
-                                <img src="img/linkedin-200.png" alt="LinkedIn Logo" />
-                            </a>
-                        <?php } } ?>
-                    </div>
-                    Use these Logos to Login or Register to the RAP facility with your social identity
-                </div>
-            <?php } ?>
-            <?php if (isset($auth['X.509']) and
-                    ( !isset($authType) or in_array('X.509', $authType))) { ?>
-                <div class="home-box">
-                    <div class="img-wrapper">
-                        <a href="x509?callback=<?php echo $session->getCallbackURL(); ?>">
-                            <img src="img/x509-200.png" alt="X.509 Logo" />
-                        </a>
-                    </div>
-                    Use the X.509 Logo to Login with your personal certificate (IGTF and TERENA-TACAR, are allowed).
-                </div>
-            <?php } ?>
-            <?php if (isset($auth['DirectIdP']) and (!isset($authType) or 
-                                      in_array('DirectIdP', $authType))) { ?>
-                <div class="home-box">
-                    <div class="img-wrapper">
-                        <a href="direct?callback=<?php echo $session->getCallbackURL(); ?>">
-                            <img src="<?php echo $auth['DirectIdP']['logo']; ?>" alt="<?php echo $auth['DirectIdP']['logo_alt']; ?>" />
-                        </a>
-                    </div>
-                    <?php echo $auth['DirectIdP']['description']; ?>
-                </div>
-            <?php } ?>
-        </div>
-    </div>
-<?php } else { ?>
-    <div class="row">
-        <div class="col-xs-12">
-            <div class="alert alert-success hide" id="info-message-alert">
-                <button type="button" class="close" aria-label="Close"><span aria-hidden="true">&times;</span></button>
-                <span class="glyphicon glyphicon-info-sign"></span>
-                <span class="info-message"></span>
-            </div>
-        </div>
-    </div>
-    <div class="row">
-        <div class="col-sm-5 col-xs-12">
-            <div class="panel panel-default" id="panel-identities">
-                <div class="panel-heading">
-                    <h3 class="panel-title">Your identities</h3>
-                </div>
-                <div class="panel-body">
-                    <?php
-                    $user = $session->user;
-                    include 'include/user-data.php';
-                    ?>
-                </div>
-            </div>
-        </div>
-        <div class="col-sm-2 text-center">
-            <button class="btn btn-success" type="button" data-toggle="modal" data-target="#search-user-modal">
-                Join with another identity
-            </button>
-        </div>
-        <div class="col-sm-5">
-            <a href="logout" class="btn btn-primary pull-right">Logout</a>
-        </div>
-    </div>
-
-    <div class="modal fade" id="search-user-modal" tabindex="-1" role="dialog" aria-labelledby="search-user-modal-title">
-        <div class="modal-dialog" role="document">
-            <div class="modal-content">
-                <div class="modal-header">
-                    <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
-                    <h4 class="modal-title" id="search-user-modal-title">Search user</h4>
-                </div>
-                <div class="modal-body">
-                    <form class="form-horizontal">
-                        <div class="form-group">
-                            <label for="user-search-text" class="col-xs-3 control-label">Search text</label>
-                            <div class="col-xs-9">
-                                <input type="text" class="form-control" id="user-search-text" placeholder="Name, surname or email...">
-                            </div>
-                        </div>
-                        <div class="form-group hide" id="user-selector-group">
-                            <label for="user-selector" class="col-xs-3 control-label">Select user</label>
-                            <div class="col-xs-9">
-                                <select id="user-selector" class="form-control">
-
-                                </select>
-                            </div>
-                        </div>
-                    </form>
-                </div>
-                <div class="modal-footer">
-                    <button class="btn btn-primary" type="button" id="send-join-request-btn">
-                        Send join request
-                    </button>
-                </div>
-            </div>
-        </div>
-    </div>
-<?php } ?>
-
-<script>
-    $(document).on('click', '#auth-panel a', showWaiting);
-</script>
-
-<?php
-include 'include/footer.php';
-
diff --git a/views/join-success.php b/views/join-success.php
deleted file mode 100644
index 0587bd0cf79f5df8a4d8d6f405584d17b75d7460..0000000000000000000000000000000000000000
--- a/views/join-success.php
+++ /dev/null
@@ -1,15 +0,0 @@
-<?php
-include 'include/header.php';
-?>
-
-<h1 class="text-center page-title">Success</h1>
-
-<br/>
-<div class="text-center">
-    <p>Your identities have been joined!</p>
-    <br/>
-    <p><a href="<?php echo $basePath; ?>">Return to index</a></p>
-</div>
-
-<?php
-include 'include/footer.php';
diff --git a/views/main-page.php b/views/main-page.php
new file mode 100644
index 0000000000000000000000000000000000000000..d069a7a22e94329f55313ea8b84dd0c018ead63a
--- /dev/null
+++ b/views/main-page.php
@@ -0,0 +1,90 @@
+<?php
+include 'include/header.php';
+?>
+<script src="js/index.js?v=<?php echo $version; ?>"></script>
+
+<div class="row">
+    <div class="col-xs-12">
+        <h1 class="text-center page-title">
+            <?php
+            if ($model->clientIcon != null) {
+                ?>
+                <img class="service-logo" src="<?php echo $model->clientIcon; ?>" alt="" />
+                <?php
+            }
+            echo $model->clientTitle;
+            ?>
+        </h1>
+    </div>
+</div>
+<div class="row" id="auth-panel">
+    <div class="col-xs-12 text-center">
+        <?php if ($model->eduGAIN || $model->orcid) { ?>
+            <div class="home-box">
+                <div class="img-wrapper">
+                    <?php if ($model->eduGAIN) { ?>
+                        <a href="auth/eduGAIN">
+                            <img src="img/edugain-100.png" alt="eduGAIN Logo" />
+                        </a>
+                    <?php } ?>
+                    <?php if ($model->orcid) { ?>
+                        <a href="auth/orcid">
+                            <img src="img/orcid-100.png" alt="ORCID Logo" />
+                        </a>
+                    <?php } ?>
+                </div>
+                Use the eduGAIN or OrcID Logo to Login or Register to RAP facility with your Institutional account. 
+            </div>
+        <?php } ?>
+        <?php if ($model->google || $model->facebook || $model->linkedIn) { ?>
+            <div class="home-box">
+                <div class="img-wrapper">
+                    <?php if ($model->google) { ?>
+                        <a href="auth/social/google" class="animated pulse">
+                            <img src="img/google-60.png" alt="Google Logo" />
+                        </a>
+                    <?php } ?>
+                    <?php if ($model->facebook) { ?>
+                        <a href="auth/social/facebook">
+                            <img src="img/facebook-60.png" alt="Facebook Logo" />
+                        </a>
+                    <?php } ?>
+                    <?php if ($model->linkedIn) { ?>
+                        <a href="auth/social/linkedin">
+                            <img src="img/linkedin-60.png" alt="LinkedIn Logo" />
+                        </a>
+                    <?php } ?>
+                </div>
+                Use these Logos to Login or Register to the RAP facility with your social identity
+            </div>
+        <?php } ?>
+        <?php if ($model->x509) { ?>
+            <div class="home-box">
+                <div class="img-wrapper">
+                    <a href="auth/x509">
+                        <img src="img/x509-200.png" alt="X.509 Logo" />
+                    </a>
+                </div>
+                Use the X.509 Logo to Login with your personal certificate (IGTF and TERENA-TACAR, are allowed).
+            </div>
+        <?php } ?>
+        <?php if ($model->localIdP) { ?>
+            <div class="home-box">
+                <div class="img-wrapper">
+                    <a href="local">
+                        <img src="<?php echo $model->localIdPConfig->logo; ?>" alt="<?php echo $model->localIdPConfig->logoAlt; ?>" />
+                    </a>
+                </div>
+                <?php echo $model->localIdPConfig->description; ?>
+            </div>
+        <?php } ?>
+    </div>
+</div>
+
+<script>
+    $(document).on('click', '#auth-panel a', showWaiting);
+</script>
+
+<?php
+include 'include/footer.php';
+
diff --git a/views/services-list.php b/views/services-list.php
index f74215ce00e839b8c09ca32526d3d97efacf8e05..5929fd778417a487bec07c55f2e284f54e1bcc40 100644
--- a/views/services-list.php
+++ b/views/services-list.php
@@ -1,35 +1,23 @@
 <?php
 /**
- * This page is specific for IA2.
+ * Shows the list of available services.
  */
 include 'include/header.php';
 ?>
 <div class="col-sm-offset-2 col-sm-10 services-list-wrapper">
     <p>Please choose the service where you want to login:</p>
     <ul>
+        <?php
+        foreach ($clients as $client) {
+            if ($client->showInHome) {
+                echo '<li>';
+                echo '<a href="' . $client->homePage . '">' . $client->title . '</a>';
+                echo '</li>';
+            }
+        }
+        ?>
         <li>
-            <form action="<?php echo $action; ?>" method="POST">
-                <input name="callback" type="hidden" value="http://archives.ia2.inaf.it/tng/rest/login/rapinput" />
-                <input type="submit" class="btn btn-link" value="Telescopio Nazionale Galileo (TNG) portal" />
-            </form>
-        </li>
-        <li>
-            <form action="<?php echo $action; ?>" method="POST">
-                <input name="callback" type="hidden" value="http://archives.ia2.inaf.it/aao/rest/login/rapinput" />
-                <input type="submit" class="btn btn-link" value="Asiago Astrophysical Observatory portal" />
-            </form>
-        </li>
-        <li>
-            <form action="<?php echo $action; ?>" method="POST">
-                <input name="callback" type="hidden" value="<?php echo $action; ?>" />
-                <input type="submit" class="btn btn-link" value="RAP Account Management" />
-            </form>
-        </li>
-        <li>
-            <form action="<?php echo $action; ?>" method="POST">
-                <input name="callback" type="hidden" value="https://sso.ia2.inaf.it/grouper" />
-                <input type="submit" class="btn btn-link" value="Grouper (groups management)" />
-            </form>
+            <a href="?action=account">RAP Account Management</a>
         </li>
     </ul>
     <br/>
@@ -37,4 +25,3 @@ include 'include/header.php';
 
 <?php
 include 'include/footer.php';
-
diff --git a/views/token-issuer.php b/views/token-issuer.php
new file mode 100644
index 0000000000000000000000000000000000000000..12ce18ace7b4b42f10d9d7ada6e5c064351b1d58
--- /dev/null
+++ b/views/token-issuer.php
@@ -0,0 +1,58 @@
+<?php
+include 'include/header.php';
+?>
+
+<div class="row">
+    <div class="col-sm-6 col-sm-offset-3">
+        <p>This panel can be used to generate tokens to be used from command line interfaces and desktop applications.</p>
+        <br/>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <h3 class="panel-title">Token issuer</h3>
+            </div>
+            <div class="panel-body">
+                <form class="form-horizontal" action="<?php echo $contextRoot . '/token-issuer'; ?>" method="post">
+                    <div class="form-group">
+                        <label for="service" class="col-sm-4 control-label">Service</label>
+                        <div class="col-sm-8">
+                            <select class="form-control" id="service" name="audit">
+                                <?php
+                                foreach ($config->services as $service) {
+                                    echo "<option value=\"$service->id\">$service->label</option>";
+                                }
+                                ?>
+                            </select>
+                        </div>
+                    </div>
+                    <div class="form-group">
+                        <label for="lifespan" class="col-sm-4 control-label">Duration (hours)</label>
+                        <div class="col-sm-8">
+                            <select class="form-control" id="lifespan" name="lifespan">
+                                <?php
+                                foreach ($config->lifespans as $lifespan) {
+                                    echo "<option>$lifespan</option>";
+                                }
+                                ?>
+                            </select>
+                        </div>
+                    </div>
+                    <div class="form-group">
+                        <div class="col-sm-8 col-sm-offset-4">
+                            <input type="submit" class="btn btn-primary" value="Download token" />
+                        </div>
+                    </div>
+                    <input type="hidden" value="<?php echo $csrfToken; ?>" name="csrf_token" />
+                </form>
+            </div>
+        </div>
+        <br/>
+        <p class="text-center">
+            <strong>
+                <a href="<?php echo $contextRoot . '/account'; ?>">Back to account manager</a>
+            </strong>
+        </p>
+    </div>
+</div>
+
+<?php
+include 'include/footer.php';