Spring Security et OpenID Connect

Notez que cet article a été mis à jour vers la nouvelle pile Spring Security OAuth 2.0. Le didacticiel utilisant l'ancienne pile est cependant toujours disponible.

1. Vue d'ensemble

Dans ce tutoriel rapide, nous allons nous concentrer sur la configuration d'OpenID Connect (OIDC) avec Spring Security.

Nous présenterons différents aspects de cette spécification, puis nous verrons le support offert par Spring Security pour l'implémenter sur un client OAuth 2.0.

2. Introduction rapide d'OpenID Connect

OpenID Connect est une couche d'identité construite au-dessus du protocole OAuth 2.0.

Il est donc vraiment important de connaître OAuth 2.0 avant de plonger dans OIDC, en particulier le flux de code d'autorisation.

La suite de spécifications OIDC est vaste; il comprend des fonctionnalités de base et plusieurs autres fonctionnalités facultatives, présentées dans différents groupes. Les principaux sont:

  • Core: authentification et utilisation des revendications pour communiquer les informations de l'utilisateur final
  • Découverte: indique comment un client peut déterminer dynamiquement des informations sur les fournisseurs OpenID
  • Inscription dynamique: détermine comment un client peut s'inscrire auprès d'un fournisseur
  • Gestion de session: définit comment gérer les sessions OIDC

En plus de cela, les documents distinguent les serveurs d'authentification OAuth 2.0 qui offrent un support pour cette spécification, en les désignant comme des «fournisseurs OpenID» (OP) et les clients OAuth 2.0 qui utilisent OIDC comme parties de confiance (RP). Nous respecterons cette terminologie dans cet article.

Il convient également de savoir qu'un client peut demander l'utilisation de cette extension en ajoutant la portée openid dans sa demande d'autorisation.

Enfin, un autre aspect qu'il est utile de comprendre pour ce didacticiel est le fait que les OP émettent des informations sur l'utilisateur final sous la forme d'un JWT appelé «ID Token».

Maintenant, oui, nous sommes prêts à plonger plus profondément dans le monde OIDC.

3. Configuration du projet

Avant de nous concentrer sur le développement proprement dit, nous devrons enregistrer un client OAuth 2.o auprès de notre fournisseur OpenID.

Dans ce cas, nous utiliserons Google comme fournisseur OpenID. Nous pouvons suivre ces instructions pour enregistrer notre application client sur leur plateforme. Notez que la portée openid est présente par défaut.

L'URI de redirection que nous avons configuré dans ce processus est un point de terminaison de notre service: // localhost: 8081 / login / oauth2 / code / google.

Nous devrions obtenir un identifiant client et un secret client à partir de ce processus.

3.1. Configuration Maven

Nous allons commencer par ajouter ces dépendances à notre fichier pom de projet:

 org.springframework.boot spring-boot-starter-oauth2-client 2.2.6.RELEASE 

L'artefact de démarrage regroupe toutes les dépendances liées à Spring Security Client, notamment:

  • la dépendance spring-security-oauth2-client pour la fonctionnalité de connexion et de client OAuth 2.0
  • la bibliothèque JOSE pour le support JWT

Comme d'habitude, nous pouvons trouver la dernière version de cet artefact en utilisant le moteur de recherche Maven Central.

4. Configuration de base à l'aide de Spring Boot

Tout d'abord, nous allons commencer par configurer notre application pour utiliser l'enregistrement client que nous venons de créer avec Google.

L'utilisation de Spring Boot rend cela très facile, car tout ce que nous avons à faire est de définir deux propriétés d'application:

spring: security: oauth2: client: registration: google: client-id:  client-secret: 

Lançons notre application et essayons d'accéder à un point de terminaison maintenant. Nous verrons que nous sommes redirigés vers une page de connexion Google pour notre client OAuth 2.0.

Cela a l'air vraiment simple, mais il se passe pas mal de choses sous le capot ici. Ensuite, nous explorerons comment Spring Security réussit.

Auparavant, dans notre article sur le support WebClient et OAuth 2, nous avons analysé les éléments internes sur la façon dont Spring Security gère les serveurs et clients d'autorisation OAuth 2.0.

Là-dedans, nous avons vu que nous devons fournir des données supplémentaires, en dehors de l'ID client et du secret client, pour configurer avec succès une instance ClientRegistration . Alors, comment ça marche?

La réponse est que Google est un fournisseur bien connu et que le framework propose donc des propriétés prédéfinies pour faciliter les choses.

Nous pouvons jeter un œil à ces configurations dans l' énumération CommonOAuth2Provider .

Pour Google, le type énuméré définit des propriétés telles que:

  • les portées par défaut qui seront utilisées
  • le point de terminaison d'autorisation
  • le point de terminaison Token
  • le point de terminaison UserInfo, qui fait également partie de la spécification OIDC Core

4.1. Accès aux informations utilisateur

Spring Security offre une représentation utile d'un utilisateur principal enregistré auprès d'un fournisseur OIDC, l' entité OidcUser .

Outre les méthodes de base OAuth2AuthenticatedPrincipal , cette entité offre des fonctionnalités utiles:

  • récupérer la valeur du jeton d'identification et les revendications qu'il contient
  • obtenir les revendications fournies par le point de terminaison UserInfo
  • générer un agrégat des deux ensembles

Nous pouvons facilement accéder à cette entité dans un contrôleur:

@GetMapping("/oidc-principal") public OidcUser getOidcUserPrincipal( @AuthenticationPrincipal OidcUser principal) { return principal; }

Ou en utilisant SecurityContextHolder dans un bean:

Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication.getPrincipal() instanceof OidcUser) { OidcUser principal = ((OidcUser) authentication.getPrincipal()); // ... }

Si nous inspectons le principal, nous verrons ici de nombreuses informations utiles, telles que le nom de l'utilisateur, l'adresse e-mail, la photo de profil et les paramètres régionaux.

De plus, il est important de noter que Spring ajoute des autorités au principal en fonction des portées qu'il a reçues du fournisseur, préfixées par « SCOPE_ ». Par exemple, la portée openid devient une autorité accordée SCOPE_openid .

Ces autorités peuvent être utilisées pour restreindre l'accès à certaines ressources, par exemple :

@EnableWebSecurity public class MappedAuthorities extends WebSecurityConfigurerAdapter { protected void configure(HttpSecurity http) { http .authorizeRequests(authorizeRequests -> authorizeRequests .mvcMatchers("/my-endpoint") .hasAuthority("SCOPE_openid") .anyRequest().authenticated() ); } }

5. L'OIDC en action

Jusqu'à présent, nous avons appris comment implémenter facilement une solution de connexion OIDC à l'aide de Spring Security

Nous avons vu l'avantage qu'il comporte en déléguant le processus d'identification de l'utilisateur à un fournisseur OpenID, qui, à son tour, fournit des informations utiles détaillées, même de manière évolutive.

Mais la vérité est que nous n'avons eu à traiter aucun aspect spécifique à l'OIDC jusqu'à présent. Cela signifie que Spring fait l'essentiel du travail pour nous.

Par conséquent, nous verrons ce qui se passe dans les coulisses pour mieux comprendre comment cette spécification est mise en œuvre et pouvoir en tirer le meilleur parti.

5.1. Le processus de connexion

Afin de voir cela clairement, activons les journaux RestTemplate pour voir les demandes que le service exécute:

logging: level: org.springframework.web.client.RestTemplate: DEBUG

If we call a secured endpoint now, we'll see the service is carrying out the regular OAuth 2.0 Authorization Code Flow. That's because, as we said, this specification is built on top of OAuth 2.0. There are, anyway, some differences.

Firstly, depending on the provider we're using and the scopes we've configured, we might see that the service is making a call to the UserInfo endpoint we mentioned at the beginning.

Namely, if the Authorization Response retrieves at least one of profile, email, address or phone scope, the framework will call the UserInfo endpoint to obtain additional information.

Even though everything would indicate that Google should retrieve the profile and the email scope – since we're using them in the Authorization Request – the OP retrieves their custom counterparts instead, //www.googleapis.com/auth/userinfo.email and //www.googleapis.com/auth/userinfo.profile, thus Spring doesn't call the endpoint.

This means that all the information we're obtaining is part of the ID Token.

We can adapt to this behavior by creating and providing our own OidcUserService instance:

@Configuration public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { Set googleScopes = new HashSet(); googleScopes.add( "//www.googleapis.com/auth/userinfo.email"); googleScopes.add( "//www.googleapis.com/auth/userinfo.profile"); OidcUserService googleUserService = new OidcUserService(); googleUserService.setAccessibleScopes(googleScopes); http .authorizeRequests(authorizeRequests -> authorizeRequests .anyRequest().authenticated()) .oauth2Login(oauthLogin -> oauthLogin .userInfoEndpoint() .oidcUserService(googleUserService)); } }

The second difference we'll observe is a call to the JWK Set URI. As we explained in our JWS and JWK post, this is used to verify the JWT-formatted ID Token signature.

Next, we'll analyze the ID Token in detail.

5.2. The ID Token

Naturally, the OIDC spec covers and adapts to a lot of different scenarios. In this case, we're using the Authorization Code flow, and the protocol indicates that both the Access Token and the ID Token will be retrieved as part of the Token Endpoint response.

As we said before, the OidcUser entity contains the Claims contained in the ID Token, and the actual JWT-formatted token, which can be inspected using jwt.io.

On top of this, Spring offers many handy getters to obtain the standard Claims defined by the specification in a clean manner.

We can see the ID Token includes some mandatory Claims:

  • the issuer identifier formatted as a URL (e.g. “//accounts.google.com“)
  • a subject id, which is a reference of the End-User contained by the issuer
  • the expiration time for the token
  • time at which the token was issued
  • the audience, which will contain the OAuth 2.0 Client id we've configured

And also many OIDC Standard Claims like the ones we mentioned before (name, locale, picture, email).

As these are standard, we can expect many providers to retrieve at least some of these fields, and therefore facilitating the development of simpler solutions.

5.3. Claims and Scopes

As we can imagine, the Claims that are retrieved by the OP correspond with the scopes we (or Spring Security) configured.

OIDC defines some scopes that can be used to request the Claims defined by OIDC:

  • profile, which can be used to request default profile Claims (e.g. name, preferred_username,picture, etcetera)
  • email, to access to the email and email_verified Claims
  • address
  • phone, to requests the phone_number and phone_number_verified Claims

Even though Spring doesn't support it yet, the spec allows requesting single Claims by specifying them in the Authorization Request.

6. Spring Support for OIDC Discovery

As we explained in the introduction, OIDC includes many different features apart from its core purpose.

The capabilities we're going to analyze in this section and the following are optional in OIDC. Hence, it's important to understand that there might be OPs that don't support them.

The specification defines a Discovery mechanism for an RP to discover the OP and obtain information needed to interact with it.

In a nutshell, OPs provide a JSON document of standard metadata. The information must be served by a well-known endpoint of the issuer location, /.well-known/openid-configuration.

Spring benefits from this by allowing us to configure a ClientRegistration with just one simple property, the issuer location.

But let's jump right into an example to see this clearly.

We'll define a custom ClientRegistration instance:

spring: security: oauth2: client: registration: custom-google: client-id:  client-secret:  provider: custom-google: issuer-uri: //accounts.google.com

Now we can restart our application and check the logs to confirm the application is calling the openid-configuration endpoint in the startup process.

We can even browse this endpoint to have a look at the information provided by Google:

//accounts.google.com/.well-known/openid-configuration

We can see, for example, the Authorization, the Token and the UserInfo endpoints that the service has to use, and the supported scopes.

An especially relevant note here is the fact that if the Discovery endpoint is not available at the time the service launches, then our app won't be able to complete the startup process successfully.

7. OpenID Connect Session Management

This specification complements the Core functionality by defining:

  • different ways to monitor the End-User's login status at the OP on an ongoing basis so that the RP can log out an End-User who has logged out of the OpenID Provider
  • the possibility of registering RP logout URIs with the OP as part of the Client registration, so as to be notified when the End-User logs out of the OP
  • a mechanism to notify the OP that the End-User has logged out of the site and might want to log out of the OP as well

Naturally, not all OPs support all of these items, and some of these solutions can be implemented only in a front-end implementation via the User-Agent.

In this tutorial, we'll focus on the capabilities offered by Spring for the last item of the list, RP-initiated Logout.

At this point, if we log in to our application, we can normally access every endpoint.

If we logout (calling the /logout endpoint) and we make a request to a secured resource afterward, we'll see that we can get the response without having to log in again.

However, this is actually not true; if we inspect the Network tab in the browser debug console, we'll see that when we hit the secured endpoint the second time we get redirected to the OP Authorization Endpoint, and since we're still logged in there, the flow is completed transparently, ending up in the secured endpoint almost instantly.

Of course, this might not be the desired behavior in some cases. Let's see how we can implement this OIDC mechanism to deal with this.

7.1. The OpenID Provider Configuration

In this case, we'll be configuring and using an Okta instance as our OpenID Provider. We won't go into details on how to create the instance, but we can follow the steps of this guide, and keeping in mind that Spring Security's default callback endpoint will be /login/oauth2/code/okta.

In our application, we can define the client registration data with properties:

spring: security: oauth2: client: registration: okta: client-id:  client-secret:  provider: okta: issuer-uri: //dev-123.okta.com

OIDC indicates that the OP logout endpoint can be specified in the Discovery document, as the end_session_endpoint element.

7.2. The LogoutSuccessHandler Configuration

Next, we'll have to configure the HttpSecurity logout logic by providing a customized LogoutSuccessHandler instance:

@Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests(authorizeRequests -> authorizeRequests .mvcMatchers("/home").permitAll() .anyRequest().authenticated()) .oauth2Login(oauthLogin -> oauthLogin.permitAll()) .logout(logout -> logout .logoutSuccessHandler(oidcLogoutSuccessHandler())); }

Now let's see how we can create a LogoutSuccessHandler for this purpose using a special class provided by Spring Security, the OidcClientInitiatedLogoutSuccessHandler:

@Autowired private ClientRegistrationRepository clientRegistrationRepository; private LogoutSuccessHandler oidcLogoutSuccessHandler() { OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler = new OidcClientInitiatedLogoutSuccessHandler( this.clientRegistrationRepository); oidcLogoutSuccessHandler.setPostLogoutRedirectUri( URI.create("//localhost:8081/home")); return oidcLogoutSuccessHandler; }

Consequently, we'll need to set up this URI as a valid logout Redirect URI in the OP Client configuration panel.

Clearly, the OP logout configuration is contained in the client registration setup, since all we're using to configure the handler is the ClientRegistrationRepository bean present in the context.

So, what will happen now?

After we login to our application, we can send a request to the /logout endpoint provided by Spring Security.

Si nous vérifions les journaux réseau dans la console de débogage du navigateur, nous verrons que nous avons été redirigés vers un point de terminaison de déconnexion OP avant d'accéder enfin à l'URI de redirection que nous avons configuré.

La prochaine fois que nous accéderons à un point de terminaison dans notre application qui nécessite une authentification, nous devrons obligatoirement nous reconnecter à notre plateforme OP pour obtenir les autorisations.

8. Conclusion

Pour résumer, dans ce tutoriel, nous avons beaucoup appris sur les solutions proposées par OpenID Connect et sur la façon dont nous pouvons en implémenter certaines à l'aide de Spring Security.

Comme toujours, tous les exemples complets se trouvent dans notre référentiel GitHub.