OpenID Connect : la bonne pratique pour l’authentification des app web

Quand on est développeur, la phase d’authentification, c’est indispensable, mais on s’en passerait bien. Il faut coder le formulaire, penser à bien protéger le mot de passe, lui appliquer une politique et offrir un moyen de le réinitialiser en cas d’oubli. Et maintenant ce n’est plus suffisant : il faut interdire les mots de passe trop simples, suivre les évolutions des recommandations (plus de vérification de complexité mais des mots de passe longs), détecter les tentatives suspectes (cette connexion qui vient tout droit de Chine était-elle vraiment légitime ?), passer en authentification forte. Et puis ce serait bien qu’on puisse utiliser un compte Facebook ou Google, et est-on bien conforme au RGPD ?

Eh bien avec OpenID Connect (OIDC pour les intimes), le développeur se passe de tout ça. Et en plus, c’est la bonne pratique. On se débarrasse de l’authentification pour la confier à quelqu’un qui sait faire. Non seulement ça fait moins de travail, mais c’est plus sécurisé et plus confortable pour les visiteurs.

Alors comment ça se passe pour le développeur ? Très bien je vous remercie, du moins une fois qu’on a assimilé les concepts de base.

On va commencer par le cas d’une application web classique, traditionnelle, avec un navigateur qui affiche les pages préparées par un serveur d’applications. Les app mobiles et les SPA seront détaillées dans une seconde partie, car elles sont un poil plus complexes.

Le fournisseur d’identités OpenID Connect

Pour utiliser OpenID Connect dans une application, il faut commencer par disposer d’un fournisseur d’identités (IdP). C’est le service qui est chargé d’authentifier les visiteurs et d’en transmettre le résultat à l’application. Il est de préférence commun à toutes les applications destinées au même public (par exemple toutes les applications internes à une entreprise, tous les services digitaux, etc.). Outre une expérience utilisateur uniforme, le partage autorise l’authentification transparente (SSO). Moins de mots de passe saisis, c’est toujours ça de gagné en confort pour les visiteurs.

Normalement le fournisseur d’identités a été déterminé avant le démarrage des développements. En fonction du contexte, ce sera un service grand public comme Google, Facebook ou Twitter (on parle de login social), ou un système déployé spécifiquement. De plus en plus, il est dans le cloud pour une meilleure fiabilité et une meilleure résilience. Car s’il n’est pas disponible, plus personne n’a accès à l’application, ce qui serait dommage.

Vous voulez des noms ? Allons-y : ForgeRock, Red Hat (KeyCloak), ADFS, Ping Identity du côté systèmes à installer. Okta, Auth0, Microsoft Azure AD dans le cloud. Pour les principaux car il y en a évidemment pleins d’autres.

L’insertion dans l’application se fait sans bouleversements

On a le fournisseur d’identités, il faut maintenant le connecter à l’application.

Nul besoin de tout redévelopper, on conserve les principes ancestraux. Avec un formulaire classique, on avait deux éléments : le formulaire de saisie du mot de passe et le code de vérification du côté serveur. Quand un visiteur se présentait à une page protégée, on le redirigeait vers le formulaire s’il n’était pas connu. Pour passer en OpenID Connect, on conserve cette architecture.

Au lieu d’afficher un formulaire, on fait une redirection vers le fournisseur d’identités et c’est lui qui présente le formulaire. Après authentification, l’IdP renvoie le visiteur vers du code côté serveur chargé de réceptionner l’identité et de faire quelques vérifications d’usage.

Tout le reste est conservé à l’identique ! En particulier – et c’est important – la gestion de la session par cookie ne bouge pas.

On rassemble ce dont on a besoin avant de coder

Le fournisseur d’identités ne va pas donner ses identités à n’importe qui. On doit avant le mettre au courant de l’application qui va lui envoyer des visiteurs. Pour cela, on n’a besoin réellement que de fournir une information : l’URL de retour, celle du code de l’application qui va recevoir et vérifier l’identité.

De son côté, le fournisseur de service donne :
• un identifiant et un mot de passe pour sécuriser les échanges (l’identifiant est appelé Client ID)
• l’URL vers laquelle l’application doit orienter les visiteurs pour les authentifier (authorization endpoint)
• l’URL où récupérer l’identité du visiteur (token endpoint). On en reparle plus loin.

Il se pose aussi la question de comment sont transmises les identités des visiteurs. Quand la base de données est locale, on se base sur une clé. Avec OpenID Connect, c’est un peu la même chose. Le fournisseur d’identités renvoie un identifiant unique. Pour déterminer lequel, il faut voir avec ceux qui le configurent et se mettre d’accord sur une information à passer.

On a en gros le choix entre :
• un identifiant technique géré par le fournisseur d’identité
• un identifiant fonctionnel qui a du sens dans le contexte des applications (le numéro d’employé pour une application RH)
• une information personnelle comme l’adresse de messagerie, qui est reconnaissable par tous

Dans l’exemple qui va suivre, on va prendre l’email comme identifiant.

Requête d’authentification vers le fournisseur d’identités

Le fournisseur d’identités est configuré, on peut passer au développement de notre application, qu’on va appeler oidc.aduneo.com et qui a un objectif ambitieux : afficher l’adresse de messagerie du visiteur authentifié.

Avec une créativité débordante, on décide que tous les accès non authentifiés sont redirigés vers l’URL https://oidc.aduneo.com/login chargée de contrôler que seuls les visiteurs habilités puissent entrer.

En développement ordinaire on afficherait un formulaire, pour OpenID Connect on crée une requête d’authentification destinée au fournisseur d’identités et on fait une redirection.

Le code est dans un langage qui n’existe pas, pour qu’il soit compréhensible par tous :

auth_request_url = « https://idp.com/openid/authorize »
+ « ?scope= »+urlencode(« openid email »)
+ « &response_type=code »
+ « &client_id= »+urlencode(« ApplicationOIDC »)
+ « &redirect_uri= »+urlencode(« https://oidc.aduneo.com/callback ») \
+ « &state= »+urlencode(state) \
+ « &nonce= »+urlencode(nonce)

server.redirect(auth_request_url)

De quoi est composée la requête d’authentification ?
• de l’URL communiquée par le fournisseur d’identité (authorization endpoint)
• de scope : on indique qu’on est en OpenID Connect (openid) et qu’on souhaite avoir l’adresse de messagerie (email)
• de response_type : par code on précise que l’application est de type web classique
• de client_id : l’identifiant de l’application donné par le fournisseur d’identités, pour qu’il sache à qui il affaire
• de redirect_uri : l’URL de retour après authentification. Attention il faut qu’elle soit exactement identique à celle qui a été configurée dans le fournisseur d’identités
• de state : un libellé aléatoire qui sert à renforcer la sécurité (on vérifiera par la suite qu’on retrouve bien le même)
• de nonce : un autre libellé aléatoire, toujours pour plus de sécurité et qui sera aussi vérifié par la suite

A ce stade, on peut déjà faire un test. Que se passe-t-il ? Le visiteur se connecte à l’application, puis il est redirigé vers le fournisseur d’identités qui lui présente une page de login. Après authentification, on lui indique que son adresse de messagerie va être transmise à notre application et ensuite il arrive sur un 404 car l’URL de retour n’existe pas encore.

Mais pas pour longtemps.

Réception de l’identité

Voyons maintenant comment coder l’URL de retour https://oidc.aduneo.com/callback.

C’est elle qui doit récupérer l’identité du visiteur. Mais pour des raisons de sécurité, l’application ne reçoit pas l’identifiant directement. Toutes ces redirections passent quand même par internet et par le navigateur, il serait un peu facile de changer l’identifiant lors du transit pour usurper l’identité d’un autre.

A la place, le fournisseur d’identités retourne un code à usage unique qui sera échangé contre l’identifiant du visiteur dans un dialogue direct entre le serveur de l’application et le fournisseur d’identité (loin donc des sales pattes des usurpateurs).

Ce code est simplement transmis dans la query string.

L’URL complète de retour ressemble à :

https://oidc.aduneo.com/callback?code=BABRfs58ff&state=756987565

Le paramètre state, c’est celui qu’on avait mis dans la requête et qui sert à vérifier que la réponse reçue correspond bien à la question posée. Evidemment, pour qu’on puisse faire la vérification, on avait mis le state de la requête dans la session de l’application juste après l’avoir généré.

On peut maintenant récupérer le code et se connecter au fournisseur d’identités pour obtenir l’identité du visiteur. Si l’URL ne contient pas de paramètre code, mais un paramètre error, c’est que tout ne s’est pas bien passé…

Il est à noter qu’à ce stade, on sait donc que le visiteur a bien été authentifié, mais on ne sait pas qui c’est (on ne peut donc pas encore savoir si on va lui ouvrir la porte ou si on le laisse dehors dans le froid).

Pour obtenir l’identité du visiteur, on va se connecter au fournisseur d’identités par un service web REST, en POST, et en donnant le code issu de la query string :

code = url.param(« code »)
response = rest.post(« https://idp.com/openid/token »,
{
« grant_type » => « authorization_code »,
« code » => code,
« redirect_uri » => « https://oidc.aduneo.com/callback« ,
« client_id » => « ApplicationOIDC »,
« client_secret » => password
}
)

L’adresse du service web, c’est le token endpoint dont on a récupéré l’URL dans les préparatifs.
Les paramètres sont les suivants :
• grant_type : on indique qu’on veut une identité en échange d’un code (authorization_code)
• code : celui qui a été donné par le fournisseur d’identités
• redirect_uri : l’URL de retour de l’application, à des fins de vérification
• client_id : l’identifiant de l’application, donné par le fournisseur d’identités
• client_secret : le mot de passe associé

En retour, on obtient un JSON, qui ressemble à ça :

{
« token_type »: ‘Bearer’,
« id_token »: ‘eyJ0eXA […] uu1OjrglxUTulA-BYejIsw’,
« access_token »: ‘eyJ0eXA […] 7kQ5BVA’
}
(si au lieu de ça on a un JSON avec un champ error, ce n’est pas idéal… La plupart du temps il faut vérifier le login et le mot de passe)

L’élément important, c’est l’id_token. Car c’est dans ce jeton sécurisé qu’on retrouve les éléments dont on a besoin. Techniquement c’est un jeton JSON JWT signé par JWS (RFC 7519 et 7515 pour ceux que ça intéresse).

Un JWS est composé de trois éléments, séparés par des points :
<en-tête>.<contenu>.<signature>

Chaque élément est codé en Base64, dans sa déclinaison Base64URL (pour pouvoir le passer dans une URL ou dans un nom de fichier).

On va commencer par décoder le contenu, ce qui donne un JSON avec les informations suivantes :
{
« sub »: « 5142695 »,
« iss »: « https://idp.com/openid »,
« aud »: « ApplicationOIDC »,
« exp »: 1568114316,
« iat »: 1568110716,
« auth_time »:1568110713,
« email »: « demo@aduneo.com »,
« nonce »: « 465686545 »
}

Les deux informations importantes sont :
• sub qui est l’identifiant unique du visiteur
• email qui est son adresse de messagerie

On sait maintenant qui est le visiteur !

Ce n’est pas fini, il faut valider le jeton

Enfin… on sait qui est le visiteur, mais il faut encore en être certain. Des personnes malintentionnées pourraient voler un jeton pour le réutiliser ailleurs, modifier un jeton, etc.

Il faut donc valider le jeton et être sûr non seulement de sa provenance, mais aussi qu’il a bien été créé pour l’authentification du visiteur actuel dans le contexte de l’authentification qui nous intéresse.

Les premières vérifications sont faciles à faire :
• dans « iss », on a le fournisseur d’identités, il faut vérifier que c’est le bon
• dans « exp », on a l’heure d’expiration du jeton (au format Unix)
• dans « aud », on a le client ID, pour vérifier que le jeton est bien pour l’application
• il faut comparer le « nonce » avec la valeur qu’on a transmise avec la requête d’authentification

Une fois qu’on est là, on a déjà beaucoup de choses. Surtout qu’au final, l’essentiel de la sécurité est assurée par l’utilisation de TLS pour sécuriser le service web entre l’application et le fournisseur d’identités, et plus précisément par la vérification du certificat présenté par ce dernier. On a l’assurance que le jeton provient bien du bon fournisseur d’identités. Si un attaquant arrive à se faire passer pour lui, il n’aura pas trop de mal à tromper toutes les autres vérifications.

Mais la norme demande d’aller plus loin et de vérifier la signature du jeton. Là, ça se complique un peu. C’est de la cryptographie et donc comme à chaque fois, il est préférable d’utiliser une bibliothèque pour le faire.

Dans les grandes lignes, il faut faire ça :
• décoder l’en-tête de l’ID token
• c’est du JSON (quelle surprise), on en extrait la valeur du champ kid
• c’est l’identifiant de la clé utilisée pour la signature
• il faut récupérer la liste des clés du fournisseur d’identités (qui les publie dans une URL)
• cette URL donne un JSON avec les clés, et pour chaque clé son identifiant kid
• ça permet de récupérer la clé et de vérifier la signature

Le plus simple, c’est de trouver une bibliothèque JWT/OAuth2 qui s’occupe de tout si on lui donne le jeton et l’URL de récupération des clés.

Le jeton d’accès pour les API

Si on remonte au moment où on récupère le jeton depuis le fournisseur d’identités, on remarquera qu’outre l’ID token, on récupère aussi un Access token (si le fournisseur d’identités est configuré pour le faire).

Ce jeton est utilisé pour sécuriser les appels par l’application à des services web externes. Si ces derniers ont besoin de l’identité du visiteur, leur passer un jeton sécurisé est la bonne démarche, plutôt que d’utiliser un simple identifiant modifiable par tous.

D’où l’Access token, transmis en utilisant la norme OAuth 2 qui fera l’objet d’un prochain article. Truc intéressant : OpenID Connect est basé sur OAuth 2, on ne sera donc pas en terrain entièrement inconnu.

Conclusion

Si on synthétise (comme c’est de rigueur pour une conclusion), faire une authentification OpenID Connect n’est pas compliqué : une direction et un appel de service web REST. Et juste ce qu’il faut de cryptographie.

Avec les notions que l’on vient de voir, un développeur pourra coder lui-même les échanges, ou savoir correctement utiliser une bibliothèque qui s’occupe de tout.

La plupart des fournisseurs d’identités proposent d’ailleurs des kits de connexion prêt à l’emploi. On évite ainsi les incompatibilités, ce qui est une bonne chose pour avancer rapidement. Mais ce qui se révèle aussi une moins bonne chose si on veut se laisser la possibilité de changer de fournisseur d’identités. Découvrir qu’il faut modifier du code en pleine migration n’est jamais agréable.

On verra la prochaine fois les spécificités des applications monopages (SPA) et des app mobiles natives avec les solutions pour en sécuriser l’authentification.

Auteur : Marc Directeur Technique Aduneo