Impersonate multidomaine avec Keycloak

La possibilité d’interagir avec une application pour le compte d’un tiers, est devenu monnaie courante. Le cas le plus commun : un utilisateur sollicitant l’aide d’un centre d’appel.

Afin d’éviter le transfert d’identifiants, qui n’est pas acceptable d’un point de vue sécurité, nous allons devoir utiliser la délégation de droits.

La plupart des produits présents sur le marché et implémentant les standards OAuth2 proposent des mécanismes d’impersonation. Cependant, les choses se compliquent un peu lorsqu’un utilisateur d’un domaine (autre que “Master”) souhaite recevoir des privilèges sur des utilisateurs qui ne font pas partie de son propre domaine (“realm” en anglais).

Dans cet article, en nous appuyant sur Keycloak (l’un des produits IAM leader de l’open source), nous allons expliquer comment réaliser cela grâce à la technique du double échange de jeton d’accès.

Vous trouverez ici la documentation officielle de Keycloak sur l’échange de jeton.

Principe du double échange de jeton

Dans le schéma ci-dessous, nous considérons un utilisateur impersonator1 (le conseiller du centre d’appel) qui souhaite utiliser une application pour le compte de l’utilisateur impersonated1 :

impersonate-multidomaine-avec-keycloak-1.jpg

L’objectif ici, est de récupérer en 7 étapes un jeton (T3) équivalent à celui que notre utilisateur impersonated1 reçoit (T) lorsqu’il s’authentifie classiquement grâce à un client my-user-app.

Dans le cas de notre démonstration, les 2 domaines sont configurés dans le même Keycloak. Impersonator-realm aurait très bien pu être un domaine d’un fournisseur de service d’identité (provider) Keycloak tiers ou même d’un autre service : solution spécifique OIDC (Open ID Connect), Saml ou social.

Voici les principales étapes :

  1. Le conseiller s’authentifie classiquement sur son domaine et récupère le jeton T1.
  2. Le conseiller demande un échange de ce jeton au client my-privileged-app du domaine impersonated-realm et récupère T2 (jeton qui le représente dans le domaine cible) :
    • Le client demande à l’adaptateur de provider de valider le jeton T1 et si un échange est possible
    • Le provider valide le jeton T1 sur l’endpoint /userinfo du domaine sur lequel impersonator1 s’est authentifié
    • 2 étapes :
      • [conditionnel] Si impersonator1 n’existe pas dans le domaine impersonated-realm, le provider crée un utilisateur lié dans le domaine impersonated-realm en effectuant un mapping permettant de lui associer le rôle IMPERSONATION (cela nécessite l’utilisation du mode first broker login)
      • On récupère un jeton correspondant à l’utilisateur lié avec ses rôles
  3. Le conseiller demande un échange du jeton T2 au client my-privileged-app du domaine impersonated-realm et récupère T3 :
    • Le client vérifie que le jeton T2 a le niveau de droits suffisant pour demander un accès sur l’utilisateur impersonated1

NOTE : Dans le domaine impersonated-realm, on aurait pu séparer le client my-privileged-app en 2 clients différents : 1 spécifique pour chaque échange de jetons.

Configuration et authentification de notre conseiller sur le domaine privilégié

Premièrement, Keycloak par défaut n’autorise pas l’échange de jeton. Cela doit nous alerter sur le fait que l’activation doit se faire avec beaucoup de précautions. Il est nécessaire au lancement du serveur, de préciser ces 2 paramètres de JVM (le mode preview n’est pas encore entièrement supporté) :

-Dkeycloak.profile=preview -Dkeycloak.profile.feature.token_exchange=enabled

La configuration proposée dans cet article est basique et simplifiée pour notre cas d’utilisation, cependant pour une sécurité optimale, il vous sera indispensable de penser à ajouter les bons algorithmes de chiffrement, de signature, la durée des jetons, les CSP, le pattern des URI de redirections, etc.

Dans un premier temps, nous allons créer un domaine par défaut impersonator-realm et lui associer le client my-privileged-app :
impersonate-multidomaine-avec-keycloak-2

Ce client sera utilisé de manière simplifiée en mode confidentiel (mode nécessaire pour initier le browser login flow), pour une récupération du jeton T1 en “implicit flow”.

On notera ici que notre client n’a pas besoin d’être public car le jeton sera récupéré directement via l’endpoint /authenticate de l’OIDC de Keycloak.

Vous auriez pu ici utiliser à la place le “standard flow” en mode public avec un code d'autorisation.

Nous allons maintenant voir comment s’authentifier à l’aide du formulaire de login Keycloak et récupérer notre jeton T1 via des commandes curl.

Dans un premier temps, on récupère le formulaire de login ainsi que les cookies de sessions (que l’on sauvegardera dans le fichier cookiefile.save) :

curl -c cookiefile.save --location --request GET 'http://localhost:8080/auth/realms/impersonator-realm/protocol/openid-connect/auth?scope=user&kc_locale=en&response_type=token&client_id=my-privileged-app&redirect_uri=http://localhost:8080/auth/realms/impersonator-realm/xxx'

Retour :

...
<form id="kc-form-login" onsubmit="login.disabled = true; return true;"
action="http://localhost:8080/auth/realms/impersonator-realm/login-actions/authenticate?session_code=Qr7epE-65fAjE5O13Js0wN7mOyGxpJCJbYDQGIwlJnA&amp;execution=a06aeb15-8c07-4fc7-87f9-4257c7ce068e&amp;client_id=my-privileged-app&amp;tab_id=2NecKNSEju4"
method="post">
...

Ce qui nous intéresse ici, c’est de récupérer l’URL d'authentification dans l’attribut action du formulaire. On pensera à remplacer les &amp; par &.

Grâce à cette URL et aux cookies que l’on va renvoyer, on va simuler un login avec nos identifiants :

curl -i -b cookiefile.save --location --request POST 'http://localhost:8080/auth/realms/impersonator-realm/login-actions/authenticate?session_code=Qr7epE-65fAjE5O13Js0wN7mOyGxpJCJbYDQGIwlJnA&execution=a06aeb15-8c07-4fc7-87f9-4257c7ce068e&client_id=my-privileged-app&tab_id=2NecKNSEju4' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'username=impersonator1' \
--data-urlencode 'password=impersonator1'

Retour (T1) :

HTTP/1.1 302 Found
...
Location: http://localhost:8080/auth/realms/impersonator-realm/xxx#session_state=6828c47d-4a30-403b-a7f6-0b2c3cef4b87&access_token=eyJhbGciOiJSUzI1NiIsInR5cCIgOiA…&token_type=bearer&expires_in=360
...

Nous pouvons extraire directement notre access_token du header Location. L’idée ici est juste de récupérer le jeton, pas de simuler une vraie redirection vers la fin du “workflow” d’authentification de notre application Web.

NOTE : Le curl effectue automatiquement la redirection 302 (elle renverra ici une 404 étant donné que nous n’avons pas spécifié de redirect_uri valide pour notre démonstration -- i. e. /xxx).

Configuration et échange des jetons de notre conseiller sur le domaine cible

Création de l’OIDC provider avec l’alias impersonator-oidc :
impersonate-multidomaine-avec-keycloak-3

impersonate-multidomaine-avec-keycloak-4

Dans notre contexte, le provider ne sera pas utilisé pour de la délégation d’authentification (comme du “social login” par exemple) mais seulement pour de l’association de comptes utilisateurs, on cochera donc Account linking only, ainsi que Hide on login page afin que le provider n’apparaisse pas en choix sur le formulaire de login.

On laissera par défaut First login flow avec la valeur first broker login afin de créer automatiquement notre utilisateur, s’il n’existe pas encore.

Enfin, on spécifiera l’endpoint /userinfo : les autres endpoints dans notre cas ne seront pas utilisés, mais comme ils sont obligatoires, on pourra les renseigner ou mettre n’importe quelle valeur (“no-use” par exemple). Cela permet de vous rendre compte de ce qui est vraiment en jeu pour du simple échange de jeton.

On notera que le client n’est pas configuré, car on ne fait que valider le jeton T1 via les APIs OIDC du domaine impersonator-realm (peu importe si l’on active ou non l'autorisation du client qui a délivré le jeton).

Intéressons-nous maintenant aux autres onglets.

Activer la capacité de faire de l’échange de jeton sur l’onglet Permissions :
impersonate-multidomaine-avec-keycloak-5

Finalement, nous allons ajouter le mapping automatique du rôle IMPERSONATION à tous les utilisateurs provenant du domaine :
impersonate-multidomaine-avec-keycloak-6

impersonate-multidomaine-avec-keycloak-7

Nous aurions pu, et c’est plus avisé, plutôt que de choisir un type Hardcoded Role, opter pour le type External ROLE to ROLE et effectuer un mapping seulement si l’utilisateur possède un rôle particulier dans le domaine impersonator-realm.

Concernant la configuration des domaines, on créera un client pour l’application privilégiée du conseiller, qui servira pour les 2 échanges de jetons (en le séparant en 2 comme déjà évoqué, cela nous aurait permis de mettre une durée de jeton très courte, de quelques secondes, pour le jeton T2 qui est éphémère) :
impersonate-multidomaine-avec-keycloak-8

On notera ici que le client doit être public afin d’exposer l’endpoint /token, mais qu’il n’initialise pas de workflow de login, donc tous les “flows” doivent être désactivés. Cela empêchera la création d’un jeton autre que pour de l’échange.

Maintenant, nous sommes en mesure d’effectuer nos échanges de jeton sur le domaine cible.

Voici le premier échange à partir de notre jeton T1, en précisant le subject_issuer correspondant à notre OIDC provider, ainsi que le subject_token (on proscrira comme indiqué dans la documentation le “naked impersonation” sans subject_token) :

curl --location --request POST 'http://localhost:8080/auth/realms/impersonated-realm/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode 'client_id=my-privileged-app' \
--data-urlencode 'subject_token=eyJhbGciOiJSUzI1NiIsInR5cCIgOiA…' \
--data-urlencode 'subject_issuer=impersonator-oidc'

Retour (T2) :

{
 "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2…",
 "expires_in": 180,
...
}

NOTE : l’utilisateur impersonator1 va être créé dans le domaine impersonated-realm avec le rôle IMPERSONATION s’il n’était pas déjà présent (Attention! Si l’utilisateur existait déjà, aucun mapping ne sera effectué et il ne récupérera pas le rôle).

Deuxième échange à partir du jeton T2 en précisant le requested_subject correspondant à l’utilisateur que l’on souhaite incarner :

curl --location --request POST 'http://localhost:8080/auth/realms/impersonated-realm/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode 'requested_token_type=urn:ietf:params:oauth:token-type:access_token' \
--data-urlencode 'client_id=my-privileged-app' \
--data-urlencode 'subject_token=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2…' \
--data-urlencode 'requested_subject=impersonated1'

Retour (T3) :

{
 "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUI…",
 "expires_in": 180,
 "refresh_expires_in": 0,
 "token_type": "bearer",
...
}

Afin de vérifier que le jeton correspond bien à notre utilisateur cible, il suffit d'interroger l’endpoint /userinfo :

curl --location --request GET 'http://localhost:8080/auth/realms/impersonated-realm/protocol/openid-connect/userinfo' \
--header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUI…'

Retour :

{
...
 "preferred_username": "impersonated1"
}

On s’assure que l’on reçoit bien impersonated1.

NOTE : Dans cet article on a pu constater que l’on n’utilisait jamais les endpoints de type /auth/admin. En interceptant les appels de la console admin de Keycloak lors d’un clic sur le bouton Impersonate d’un utilisateur, on constate (visible dans le suivi de l’activité réseau de l’outil de développement d’un browser) que c’est l’endpoint /auth/admin/realms/**/impersonation, qui est utilisé :

http://localhost:8080/auth/admin/realms/impersonated-realm/users/5e4b1191-c7ef-4e21-a667-4887db7f7c62/impersonation

Cet endpoint ne doit pas être utilisé pour l'implémentation de l'impersonation, il faut suivre la méthode décrite précédemment dans le document. En effet l'utilisation de cet endpoint nécessiterait de :

  • Devoir exposer au niveau du pare-feu le pattern /auth/admin/** sur le réseau externe
  • Être en mesure de connaître l’ID de l’utilisateur (alors qu’avec un échange on peut utiliser directement le nom dans le requested_subject)
  • Créer une nouvelle session (cookies) :
    • Perte de la session sur le domaine impersonator-realm
    • Nécessité de faire un appel supplémentaire pour récupérer un jeton sur cette session

Conclusion

En fin de compte, l’exercice ne demande que très peu de configuration et l’on imagine facilement la possibilité de créer des ensembles (domaines) plus complexes avec un périmètre très restreint. Grâce à l’échange de jeton, il est possible de déléguer des droits à l’infini pour différents types de providers supportant OAuth2.

Cependant, il faut bien être conscient que plus le système est complexe, plus le nombre de failles potentielles ouvertes sera important, d’autant plus que dans le cas de l’impersonate, la fuite d’un jeton d’un profil privilégié peut permettre d’usurper l’identité de n’importe quel utilisateur du domaine acceptant cet impersonate.

Enfin, on n'oubliera pas dans notre solution de réfléchir sur notre application frontale, aux mécanismes de rafraîchissement de la chaîne de nos jetons à partir de la session initiale.