Authentification OAuth2 avec clés de service et Tokens

Cette méthode d’authentification permet une authentification de machine à machine par l’utilisation de clés de service, de demandes signées de et des Tokens d’accès de courte durée.

Cette méthode est adaptée à l’authentification des applications de service (p. ex. applications spécialisées), qui peuvent gérer l’authentification de manière indépendante et sans interaction humaine.

Vue d’ensemble

Cette authentification des Requests s’effectue par l’intermédiaire d’Access-Tokens de courte durée de vie, qui doivent être régulièrement renouvelés par l’application de service.

La procuration d’un Access-Tokens se fait par un soi-disant “JWT Authorization Grant” - une requête signée, par laquelle l’application de service demande un nouvel Access-Token.

Cet Authorization Grant doit être signé à l’aide d’une clé privée, liée à un utilisateur spécifique et qui a été créée dans GEVER via l’interface utilisateur.

Flux d’authentification

Le processus comprend quatre étapes:

  1. Émission d’une Clé de service pour un utilisateur dans GEVER, et dépôt de cette clé dans l’application de service.
  2. L’application de service génère un JWT Authorization Grant, afin de demander un Access-Token, signant ce Grant avec la clé privée.
  3. L’application de service utilise ce JWT Grant pour récupérer un Access Token dans GEVER.
  4. L’application de service utilise ensuite cet Access-Token pour authentifier ses Requests sur GEVER (Jusqu’à son expiration, où un nouveau JWT Grant doit être crée pour à nouveau récupérer un Token).

Si une clé de service a déjà été créée pour l’utilisateur et a été déposée dans l’application de service, le flux d’authentification se déroule comme suit:

tokenauth-auth-flow

Gestion de clés de service

../../../../_images/tokenauth-manage-keys-action.png

Les clés de service d’un compte peuvent être administrées via l’interface utilisateurs de OneGov GEVER. Pour les comptes, pour lesquels l’émission de clés a été autorisée, l’interface de gestion est accessible via l’action Gérer les clés de service est accessible dans le menu des réglages personnels.

tokenauth-manage-keys


Via l’action :guilabel :Émettre une nouvelle clé de service une nouvelle clé peut être généré. Au minimum un titre doit être attribué à la clé et devrait décrire l’utilisation prévue.

optionnellement, il est possible de définir une plage IP, à partir de laquelle les jetons d’accès liés à la clé peuvent être utilisés pour l’authentification.

tokenauth-issue-key


Après sa génération, la clé privée est affichée exactement une fois et doit être enregistrée. La clé publique reste sur le serveur et la clé privée est à stocker dans un système de fichiers de manière à ce qu’uniquement l’application de service puisse y accéder.

tokenauth-download-key


Dans le formulaire de modification, il est possible d’éditer le titre et l’IP-Range de clés existantes. Les modifications de l’IP-Range autorisée sont immédiatement pris en compte et sont valables également pour les Access Tokens qui ont déjà été générés à l’aide de cette clé.

tokenauth-edit-key


L’heure et la date de la dernière fois que la clé a été utilisée pour récupérer un Access-Token est affichée dans la vue d’ensemble de l’interface de gestion

En cliquant sur cette date, il est possible de visualiser des logs détaillés des dernières utilisations de la clé.- Une utilisation est enregistrée dans l’historique lorsque la clé respective utilise le JWT Authorization Grant qui y correspond pour récupérer un Access-Token.

tokenauth-usage-logs

Créer un JWT Authorization Grant

Pour obtenir un Access Token, l’application de service génère un JWT Authorization Grant, et signe ce dernier à l’aide de sa clé privée.

Un Authorization Grant est un JWT (JSON Web Token) avec lequel un lot de Claims prédéfinis qui, hormis leur timestamp, peuvent toutes être dérivées de la clé de service.

Le JWT doit contenir les Claims suivants:

Nom Description
iss Issuer - la client_id provenant de la Service-Key
aud Audience - la token_uri provenant de la Service-Key
sub Subject - la user_id provenant de la Service-Key
iat La date et heure à laquelle le Grant a été émis, indiqué au format Unix-Timestamp [1]
exp la date d’éxpiration du JWTs, au format Unix-Timestamp [1]. Maximum: 1 jour, recommandé: 1 heure
[1](1, 2) secondes depuis epoch (00:00:00 UTC, 1er Janvier, 1970).

Le JWT doit être signé par la clé privée. RS256 (Signature RSA avec SHA256) est le seul algorithme de signature supporté.

Pour les applications .NET, il existe une librairie du nom de Jwt.Net, qui peut être utilisée pour la signature de JWTs.

Exemple en Python:

import json
import jwt
import time

# Load saved key from filesystem
service_key = json.load(open('my_saved_key.json', 'rb'))

private_key = service_key['private_key'].encode('utf-8')

claim_set = {
    "iss": service_key['client_id'],
    "sub": service_key['user_id'],
    "aud": service_key['token_uri'],
    "iat": int(time.time()),
    "exp": int(time.time() + (60 * 60)),
}
grant = jwt.encode(claim_set, private_key, algorithm='RS256')

Obtenir un Access-Token

Pour obtenir un Access-Token, l’application client effectue une Token-Request, pour échanger le JWT créé et signé précédemment contre un Token.

La Token-Request doit être effectuée sur la token_uri définie dans la Service-Key. Cette Request doit être du type POST, ayant pour Content-Type: application/x-www-form-urlencoded et, dans le body, les paramètres form-encoded.

Deux paramètres sont requis:

Nom Description
grant_type Doit toujours être urn:ietf:params:oauth:grant-type:jwt-bearer
assertion Le JWT Authorization Grant

L’Endpoint Token répond ensuite avec une Token Response, contenant l’Access-Token:

{
  "access_token": "<token>",
  "expires_in": 3600,
  "token_type": "Bearer"
}

Cette Response est du type Content-Type: application/json et contient un JSON Body encodé.

Exemple en Python:

import requests

GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'

payload = {'grant_type': GRANT_TYPE, 'assertion': grant}
response = requests.post(service_key['token_uri'], data=payload)
token = response.json()['access_token']

En cas d’erreur, le Token Endpoint répond avec un JSON-Dictionary, contenant les détails de l’erreur:

{
  "error": "invalid_grant",
  "error_description": "<Description de l'erreur>"
}

Utiliser l’Access Token pour l’autentification

L’application client peut ensuite utiliser l’Access-Token pour authentifier ses Requests. Le Token doit être envoyée en tant que Bearer pour son header HTTP Authorization.

Une fois que le Token a expiré, l’application client doit créer et signer un nouveau JWT Grant et l’utiliser pour obtenir un nouveau Token.

Exemple en Python:

with requests.Session() as session:
    session.headers.update({'Authorization': 'Bearer %s' % token})
    response = session.get('http://localhost:8080/Plone/')
    # ...

Lorsque le Token envoyé par l’application client est expiré, le serveur retourne l’erreur suivante:

{
  "error": "invalid_token",
  "error_description": "Access token expired"
}

Le client doit, dans ce cas, créer et signer un nouveau JWT, et réitérer la Request précédemment échouée avec le nouveau Token.

Implémentation client recommandée

Les étapes décrites ci-dessus présentent un cas simple, où un client ne doit que s’authentifier une seule et unique fois.

Pour un client qui doit effectuer des Requests continuellement, il est nécessaire d’implémenter une certaine logique pour renouveler le Token de manière récurrente.

Cette logique devrait être à peu près implémentée comme suit:

tokenauth-client-flow

Au lieu d’essayer de prédire la date d’expiration du jeton, le client doit s’attendre à ce que chaque requête puisse échouer à cause d’un jeton expiré. Dans ce cas, il peut obtenir un nouveau jeton et répéter la demande avec ce dernier.

Pour l’implémentation, nous recommandons donc de déléguer l’exécution de Requests à une classe contenant toute la logique de retry et d’éviter de soumettre des requêtes directement depuis la logique business de l’application client.

Lors de l’exécution de requêtes dans le but d’obtenir de nouveaux Tokens, 2 choses sont à considérer:

  • Ces requêtes ne doivent pas contenir de Header ´´Authorization´´ au risque d’échouer, entre autres, lorsque celles-ci envoient un Token expiré.
  • Ces requêtes doivent, comme décrit ci-dessus, être exécutées avec Content-Type: application/x-www-form-urlencoded alors que les requêtes sur l’API GEVER doivent contenir Content-Type: application/json.

Pour ces raisons, il est recommandé d’utilser des sessions distinctes (connexions http persistantes) pour les requêtes normales et le renouvellement de Tokens.

Exemple d’implémentation en Python pour un client à authentification continue.

Exemple de client (Python)
import json
import jwt
import requests
import time


KEY_PATH = './my_key.json'

GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'


class Client(object):

    def __init__(self):
        self.session = requests.Session()

    def request(self, method, url, **kwargs):
        # First request will always need to obtain a token first
        if 'Authorization' not in self.session.headers:
            self.obtain_token()

        # Optimistically attempt to dispatch reqest
        response = self.session.request(method, url, **kwargs)
        if self.token_has_expired(response):
            # We got an 'Access token expired' response => refresh token
            self.obtain_token()
            # Re-dispatch the request that previously failed
            response = self.session.request(method, url, **kwargs)

        return response

    def token_has_expired(self, response):
        status = response.status_code
        content_type = response.headers['Content-Type']

        if status == 401 and content_type == 'application/json':
            body = response.json()
            if body.get('error_description') == 'Access token expired':
                return True

        return False

    def obtain_token(self):
        print "Obtaining token..."
        private_key, client_id, user_id, token_uri = self.load_private_key()

        iat = int(time.time())
        exp = iat + (60 * 60)
        claim_set = {
            "iss": client_id,
            "sub": user_id,
            "aud": token_uri,
            "exp": exp,
            "iat": iat,
        }
        grant_token = jwt.encode(claim_set, private_key, algorithm='RS256')
        payload = {'grant_type': GRANT_TYPE, 'assertion': grant_token}
        response = requests.post(token_uri, data=payload)
        token = response.json()['access_token']
        # Update session with fresh token
        self.session.headers.update({'Authorization': 'Bearer %s' % token})

    def load_private_key(self):
        keydata = json.load(open(KEY_PATH, "rb"))
        private_key = keydata['private_key'].encode('utf-8')
        client_id = keydata['client_id']
        user_id = keydata['user_id']
        token_uri = keydata['token_uri']
        return private_key, client_id, user_id, token_uri


def main():
    client = Client()
    # Issue a series of API requests an an example
    client.session.headers.update({'Accept': 'application/json'})

    for i in range(10):
        response = client.request('GET', 'http://localhost:8080/fd/')
        print response.status_code
        time.sleep(1)


if __name__ == '__main__':
    main()