Incorporer au jeton JWT des déclarations supplémentaires

, par B. Degoy

Les spécifications d’OAuth 2.0 autorisent l’ajout de nouvelles déclarations (claims) à la charge utile du jeton JWT.
Cet article montre comment cela est réalisé dans le cadre d’OAuthSD.

Cas d’usage

Il convient de s’assurer que l’utilisateur final est bien habilité à accéder à des ressources protégées, qu’il s’agisse de données personnelles ou que l’application soit propriétaire des données. On voudrait également pouvoir contrôler les données transmises par une ressource protégée à une application en fonction des droits de l’utilisateur final dans cette application.

Les administrateurs aussi bien que les utilisateurs finaux utiliseront OpenID Connect pour s’authentifier sur une application. On voudra donc moduler les droits et privilèges de l’utilisateur final en fonction de son identité, de son appartenance ou de son profil. L’exemple le plus simple est la distinction entre des droits de lecture des données et des droits de modification. On peut également envisager d’attribuer à l’utilisateur des droits d’administration de tout ou partie d’une application.

Ce n’est pas le rôle du serveur d’authentification, qui se doit d’être transparent par rapport aux portées d’autorisation (scopes) mis en oeuvre par une application. Pour cela, OAuthSD permet à une application externe d’incorporer des déclarations supplémentaires aux jetons JWT, qu’il s’agisse d’un jeton d’accès au format JWT ou d’un jeton d’identité. Les ressources protégées "sauront" interpréter les données du JWT pour contrôler leur réponse.

Remarque quant à la sécurité des données

Rappelons que la charge utile du jeton JWT n’est pas cryptée, mais seulement "URL64-encoded". Il convient donc de ne pas transmettre de cette façon des données sensibles.

L’intérêt de passer des données supplémentaires dans la charge utile du jeton JWT est de les lier de façon infalsifiable à l’identité du client et à celle de l’utilisateur à l’aide de la signature. Cela est particulièrement pertinent pour transmettre les privilèges d’un utilisateur ou d’une application à une ressource protégée. Dans cette optique, on peut :
– soit transmettre directement les privilèges ; par exemple un rôle qui prendrait des valeurs telles que ’administrateur’, ’redacteur’, ’propriétaire’, ’utilisateur’, etc., ou tout autre code compris par la ressource,
– soit transmettre les informations sur l’utilisateur qui permettront à la ressource protégée d’établir ses privilèges.

Mais attention dans ce deuxième cas : il ne faudra pas dévoiler de données sensibles sur l’utilisateur. Si la transmission de telles données à la ressource protégée s’avérait indispensable, la ressource protégée pourra interroger le service Userinfo.
On pourrait penser à un jeton crypté (JWE), mais cela nécessiterait, outre un développement spécifique, de s’écarter du standard OpenID Connect. Il serait beaucoup plus simple de crypter les informations supplémentaires en un seul bloc et de les passer dans une déclaration unique.

Il faut également faire attention à ne pas dépasser la longueur admise pour un JWT (à préciser).

Transmission de privilèges de l’utilisateur final avec l’authentification

OAuthSD offre deux méthodes complémentaires pour incorporer dans le JWT des informations relatives à un utilisateur final :

– l’écriture de données supplémentaires dans les champs ’profile’ et ’scope’ de la table users à l’aide du service HTTP Rest. Il s’agit d’un processus asynchrone dans la mesure où les données sont inscrites à un moment choisi par une application extérieure, indépendamment de l’interrogation du serveur d’autorisation.

OAuthSD utilise l’étendue d’autorisation (scope) "privileges" pour contrôler l’accès à ces informations. Quand une application présente le scope ’privileges’ dans la demande d’autorisation, les déclarations ’profil’ et ’scope’ sont inscrites dans la réponse Userinfo ainsi que dans la charge utile du jeton JWT. L’accès à l’API HTTP REST + TreeQL donne un bon exemple de transmission de privilèges.

– l’incorporation de données supplémentaires rafraîchies au moment de la fabrication du jeton JWT, par exemple en interrogeant un service externe. Il s’agit d’un processus synchrone ou de "scope dynamique", permettant d’obtenir des informations en phase avec celles détenues par les applications de gestion des utilisateurs dans le cas où cette gestion est extérieure au serveur.

Cette deuxième méthode est développée maintenant.

Incorporer des données supplémentaires à la charge utile du jeton JWT

Cette fonctionnalité est propre à une utilisation d’OAuthSD dans un groupe d’applications maîtrisées par une même organisation (Corporate Realm).

La bibliothèque oauth2-server-php offre une fonction reconfigurable pour insérer des déclarations supplémentaires dans un JWT au moment de sa création (depuis la version 1.10.0).

Pour incorporer des données supplémentaires, il faut :
– fixer la constante de configuration EXTRA_PAYLOAD à ’true’.
– définir le contenu de la fonction extra_payload() du fichier /oidc/interface/extrapayload.php.
La fonction extra_payload() doit être écrite par le concepteur des applications. Ce peut être un appel à un webservice tiers. La fonction reçoit les paramètres client_id, user_id et scope et doit retourner un array de ’claim’ => ’valeur’ qui sera combiné avec les déclarations de la charge utile sans pouvoir remplacer les déclarations standard.

Notes :
– Cette méthode permet de créer des déclarations ayant un nom quelconque. Cependant, les déclarations standard d’OIDC (’id’, ’jti’, ’iss’, ’aud’, ’sub’, ’exp’, ’iat’, ’token_type’ et ’scope’) ne pourront être surchargées : elles garderont leur nom et leur valeur. Si une déclaration ’scope’ est retournée, elle sera incorporée à la charge utile du JWT sous le nom "extra_scope".

– Les informations sont rafraîchies au moment de la création du jeton JWT ou de son rafraîchissement. Cela implique que ces informations ne sont pas strictement synchrones de leur correspondantes dans le système extérieur. Si les droits d’un utilisateur changent dans le système extérieur, il conviendra de révoquer la session de l’utilisateur afin de provoquer une nouvelle demande d’authentification ce qui entraînera la création d’un nouveau jeton JWT. Mais dans le cas où il serait nécessaire de modifier un grand nombre d’utilisateurs, une autre façon d’assurer le synchronisme sera de fermer le service le temps de la durée de vie d’un JWT.

– Cette méthode n’écrit pas dans la table users et, en particulier, dans le champ "scope". Si les déclarations supplémentaires comprennent une déclaration "scope" (ce qui sera généralement le cas), la valeur du champ "scope" de la table users sera incorporée à la charge utile du JWT sous le nom "user_scope".

– Dans tous les cas, la valeur du paramètre ’scope’ passé à l’appel d’authorize par l’application sera incorporé sous le nom ’requested_scope’ [1].

Voyez ci-dessous le code d’un exemple extrayant d’un web-service des données supplémentaires.

P.-S.

Exemple : Données supplémentaires issues d’un Web Service

Dans OAuthSD, cette fonctionnalité est implémentée de la façon suivante :

Il faut tout d’abord définir les constantes nécessaires dans la configuration :

/** [dnc13']
* If true, Authorize will call the function obtain_extra_payload() of 
* /oidc/interface/extrapayload.php to obtain extra data for claims.  
*/
define('EXTRA_PAYLOAD', true);
/* If not null, the function obtain_extra_payload() will check this WS to obtain extra data.  
*/
define('WS_EXTRA_PAYLOAD_URL', 'https://xxxx.xxx');

La fonction extra_payload est définie comme étant la fonction reconfigurable appelée au moment de la construction de la charge utile du JWT :

//[dnc13'] Enable getting extra payload for JWT
if ( EXTRA_PAYLOAD ) {
    $server_config['jwt_extra_payload_callable'] = 'extra_payload';
}

La fonction est définie dans le dossier /oidc/interface/extrapayload.php. Voici un exemple obtenant les données par appel à un web service :

PHP

<?php
/* extrapayload.php
OauthSD project

Auteur : Bertrand Degoy 
Copyright (c) 2019 DnC  
Licence GPLv3
*/


/** [dnc13'] Projet I
* Fonction de callback appellée par JwtAccessToken::createPayload()
* Retourne des déclarations (claims) à introduire dans le jeton d'identité.
* Les données sont extraites d'un webservice.
* Deux retours du webservice sont acceptés :
* - une valeur scalaire : elle sera affectée à la déclaration extra_scope dans la charge utile du jeton d'identité.
* - un array associatif au format JSON : après décodage en array, chaque composante  
* de premier niveau sera préparée en vue d'être incorporée à la charge utile du jeton JWT.
* Voir : 
*   https://oa.dnc.global/web/133
*   https://github.com/bshaffer/oauth2-server-php/pull/804
*   
* @param string $user_id : utilisateur final identifié par le contrôleur Authorize,
* @param string $client_id : application cliente passée aux contrôleurs Authorize et Token,
* @param string $requested_scopes : les scopes passés au contrôleur Authorize, 
* @param mixed $cnx : objet PDO connecteur à la base de données.
* @return array (empty on error).
*/
function extra_payload( $client_id, $user_id, $requested_scope ) {

    if ( EXTRA_PAYLOAD ) {

        $extra = obtain_extra_payload( $client_id, $user_id, $requested_scope );

        $result = array();

        if ( $extra !== false ) {

            if ( is_array( $a_extra = json_decode($extra, true)) ) {         
                // array     
                $standards = "id jti iss aud sub exp iat token_type scope";
                foreach( $a_extra as $key => $value ) {
                    if ( strpos($standards, $key) !== false ) {
                        $key = 'extra_' . $key;
                    }
                    $result[$key] = $value;
                }

            } else if ( !empty($extra) ) {
                // valeur scalaire : déclaration extra_scope
                $result['extra_scope'] = $extra;

            } else {
                // vide
                // Connect to database
                global $connection;
                $cnx = new \PDO($connection['dsn'], $connection['username'], $connection['password']);
                // Log error
                log_error('ExtraPayloadWS', 'Empty data', $client_id, $user_id, 901, 0, $cnx);

            } 

        } else {
            // Erreur de communication ou erreur du WS. Déjà dans le log.

        }

    }

    // intégrer requested_scope si $result n'est pas vide
    if ( !empty($result) )
        $result['requested_scope'] = $requested_scope;

    return $result;  // vide en cas d'erreur

}


/** [dnc13'] Projet I
* Obtenir d'un webservice des données supplémentaires.
* L'autorisation d'accès au WS est obtenue en passant le secret de l'application par la méthode Authorization Bearer.
* @param string $user_id : utilisateur final identifié par le contrôleur Authorize,
* @param string $client_id : application cliente passée aux contrôleurs Authorize et Token,
* @param string $requested_scopes : les scopes passés au contrôleur Authorize,
* @return mixed false, string or array() les données reçues du WS sont retournées sans modification. 
*/
function obtain_extra_payload( $client_id, $user_id, $requested_scope ) {

    if ( ! is_null(WS_EXTRA_PAYLOAD_URL)) {

        include_once(__DIR__ . '../../commons/configure_oidc.php');

        // Connect to database
        global $connection, $storage_config;
        $cnx = new \PDO($connection['dsn'], $connection['username'], $connection['password']); 

        // Get the client secret
        $stmt = $cnx->prepare(sprintf('SELECT client_secret FROM %s WHERE client_id=:client_id', $storage_config['client_table']));
        $stmt->execute(compact('client_id'));
        $data = $stmt->fetch(\PDO::FETCH_ASSOC);

        if ( !empty($client_secret = trim($data['client_secret']) ) ) { 

            // Obtain data from WS at WS_EXTRA_PAYLOAD_URL
            $data = array(  // Parameters are POSTed to the WS.
                'user_id'       => $user_id, 
                'client_id'     => $client_id,
                'passed_scope'  => $requested_scope,  // value passed to Authorize in the request    
            );
            $h = curl_init(WS_EXTRA_PAYLOAD_URL); 
            curl_setopt($h, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($h, CURLOPT_TIMEOUT, 10);
            curl_setopt($h, CURLOPT_HTTPHEADER, array('Authorization: Bearer ' . $client_secret));
            curl_setopt($h, CURLOPT_POST, true);
            curl_setopt($h, CURLOPT_HTTPHEADER, array('Content-Type: application/x-www-form-urlencoded'));   
            curl_setopt($h, CURLOPT_POSTFIELDS, http_build_query($data));
            //curl_setopt($h, CURLOPT_SSL_VERIFYPEER, false);

            $result = curl_exec($h);

            if ( !empty($result) ) {       
                curl_close($h);
                return $result;

            } else {
                // Curl error during WS request.
                $error = curl_error($h);   
                curl_close($h);
                // Log error
                log_error('ExtraPayload', 'Comm or WS error: ' . $error, $client_id, $user_id, 900, 0, $cnx);
                return false;
            }

        } else
            // No client secret, public application?
            return false;

    } else
        // No WS URL 
        return false;

}

Notes :

– L’authentification de l’appel au web service est faite en passant le secret de l’application par la méthode Authorization : Bearer. Ceci suppose que le WS est intégré à l’application de supervision du serveur, celle-ci accédant aux données des applications clientes. Si ce n’était pas le cas, il faudrait modifier le code pour passer une chaîne secrète connue des deux parties.

– Le web service reçoit les identifiants de l’application cliente et de l’utilisateur final, ainsi que les portées demandées par l’application. Il pourra s’en servir pour appliquer les règles métier qui conduiront à définir la réponse, ou à ne pas répondre.

– Implémentation de la fonction extra_payload() dans oauth2-server-php :
voir https://github.com/bshaffer/oauth2-server-php/pull/804

Exemples

Chaîne transmise Retour extra_payload
"1,2" array = extra_scope : string = "1,2"
"{"\"scope\":\"1,2\"}" array = extra_scope : string = "1,2"
"{\"admin\":\"1\"}" array = admin : string = "1"
"admin=1" array = extra_scope : string = "admin=1"
"admin=\"1\"" array = extra_scope : string = "admin=\"1\""
"{\"key1\":\"value1\",\"key2\":\"value2\",\"key3\":\"value3\"}" array =
key1 : string = "value1"
key2 : string = "value2"
key3 : string = "value3"

Notes

[1] Le nom "scope" est malheureusement utilisé par Oauth 2.0 pour des concepts différents. Il est donc naturel d’utiliser des noms différents pour éviter de mélanger ou d’écraser des données.