OAuth 2.0 et OpenID Connect par l'exemple avec Spring Boot 3

1. Aperçu

OAuth 2.0 et OpenID Connect (OIDC) sont souvent mentionnés ensemble, c’est pourquoi beaucoup les considèrent comme une seule entité. En réalité, ils résolvent deux problèmes différents. OAuth 2.0 s’occupe de l’autorisation (permettre à une application d’appeler une API au nom d’un utilisateur). OIDC s’occupe de l’authentification (savoir qui est l’utilisateur).

Dans cet article, vous allez comprendre le flux Authorization Code + PKCE. Ensuite, vous allez créer deux applications Spring Boot 3 : un client se connectant via OIDC et un serveur de ressources vérifiant les access tokens au format JWT. À la fin de l’article, vous trouverez des tests pour le serveur de ressources et une liste d’erreurs courantes.

2. Où diffèrent OAuth 2.0 et OpenID Connect

2.1. Les rôles dans OAuth 2.0

OAuth 2.0 définit quatre rôles :

  • Resource Owner : l’utilisateur, propriétaire des données (par exemple, un client qui a des commandes).
  • Client : l’application qui souhaite accéder aux données (par exemple, le site web shop-web).
  • Authorization Server : l’endroit où l’utilisateur se connecte et accorde l’accès, puis émet les tokens. Exemples : Keycloak, Okta, Auth0.
  • Resource Server : l’API contenant les données (par exemple, order-api).

Le résultat d’OAuth 2.0 est un access token. Ce token est destiné au serveur de ressources, pas au client. Le client le porte simplement et l’envoie. Il ne devrait pas le lire pour déduire qui est l’utilisateur.

2.2. Ce que OIDC ajoute

OIDC est une couche au-dessus d’OAuth 2.0. Quand le client demande le scope openid, le serveur d’autorisation retourne également un ID token. C’est toujours un JWT, signé, contenant les informations de connexion :

{
  "iss": "http://localhost:8180/realms/shop",
  "sub": "customer-42",
  "aud": "shop-web",
  "exp": 1760353200,
  "iat": 1760352900,
  "nonce": "n-0S6_WzA2Mj",
  "email": "lan@example.com",
  "name": "Nguyễn Thị Lan"
}

Les claims importants incluent :

  • iss : qui a émis le token.
  • sub : l’identifiant unique et stable de l’utilisateur.
  • aud : pour quel client ce token est destiné.
  • nonce : prévention contre la rejeu.

Vous pouvez résumer cela ainsi :

Access token  -> envoyé à l'API (serveur de ressources)  -> "ce qu'on peut faire"
ID token      -> le client le lit pour se connecter        -> "qui est l'utilisateur"

3. Flux Authorization Code + PKCE

Pour les applications web, les SPA et les applications mobiles, vous devriez utiliser le flux Authorization Code, avec PKCE (Proof Key for Code Exchange) en supplément. Le flux Implicit et la subvention Password ne sont plus recommandés.

1. L'utilisateur clique sur "Connexion" sur shop-web
2. shop-web crée un code_verifier aléatoire, calcule code_challenge = SHA256(code_verifier)
3. Redirection du navigateur vers :
   /authorize?response_type=code&client_id=shop-web&scope=openid profile email
             &redirect_uri=...&state=xyz&nonce=abc
             &code_challenge=...&code_challenge_method=S256
4. L'utilisateur se connecte à Keycloak, Keycloak le redirige vers :
   /login/oauth2/code/shop?code=SplxlOBe&state=xyz
5. shop-web (back-channel) appelle POST /token avec code + code_verifier + client_secret
6. Keycloak retourne : access_token, id_token, refresh_token
7. shop-web vérifie id_token (signature, iss, aud, exp, nonce) -> crée une session
8. shop-web appelle order-api avec l'en-tête : Authorization: Bearer <access_token>

Chaque paramètre bloque un type d’attaque particulier :

  • state prévient la CSRF lors du callback.
  • nonce lie l’ID token à la session de connexion correcte.
  • PKCE s’assure que même si quelqu’un vole le code, il ne peut pas l’échanger contre un token sans le code_verifier.

La bonne nouvelle est que Spring Security fait presque tout le travail pour vous.

4. Créer un client de connexion OIDC

4.1. Dépendances et configuration

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Supposons que Keycloak s’exécute sur le port 8180, avec un realm shop et un client shop-web :

spring:
  security:
    oauth2:
      client:
        registration:
          shop:
            provider: keycloak
            client-id: shop-web
            client-secret: ${SHOP_CLIENT_SECRET}
            authorization-grant-type: authorization_code
            scope: openid, profile, email, orders.read
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          keycloak:
            issuer-uri: http://localhost:8180/realms/shop

Vous n’avez besoin de déclarer que issuer-uri. Spring lira /.well-known/openid-configuration pour récupérer automatiquement les endpoints authorize, token, jwks et end_session. Le scope openid transforme le flux OAuth 2.0 en connexion OIDC.

4.2. Configuration de la sécurité

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http,
                                            ClientRegistrationRepository registrations) throws Exception {
        var resolver = new DefaultOAuth2AuthorizationRequestResolver(
                registrations, "/oauth2/authorization");
        resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());

        var logoutHandler = new OidcClientInitiatedLogoutSuccessHandler(registrations);
        logoutHandler.setPostLogoutRedirectUri("{baseUrl}");

        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/").permitAll()
                .anyRequest().authenticated())
            .oauth2Login(login -> login
                .authorizationEndpoint(ep -> ep.authorizationRequestResolver(resolver)))
            .logout(logout -> logout.logoutSuccessHandler(logoutHandler));
        return http.build();
    }
}

Cette configuration fait trois choses :

  • oauth2Login active tout le flux décrit à la section 3, y compris la redirection, le callback, l’échange du code et la vérification du ID token.
  • withPkce() ajoute code_challenge même pour les clients confidentiels (clients avec secret). Les clients publics ont déjà PKCE activé par Spring par défaut.
  • OidcClientInitiatedLogoutSuccessHandler se déconnecte aussi du côté Keycloak (RP-initiated logout). Sans cela, si l’utilisateur clique sur “Se connecter” à nouveau, il sera connecté sans avoir besoin d’entrer son mot de passe.

4.3. Lire les informations utilisateur et appeler l’API

@RestController
public class AccountController {

    private final RestClient orderApi = RestClient.create("http://localhost:8081");

    @GetMapping("/me")
    public Map<String, Object> me(@AuthenticationPrincipal OidcUser user) {
        return Map.of(
                "subject", user.getSubject(),
                "email", Objects.requireNonNullElse(user.getEmail(), "(pas disponible)"),
                "name", Objects.requireNonNullElse(user.getFullName(), "(pas disponible)"));
    }

    @GetMapping("/my-orders")
    public String myOrders(@RegisteredOAuth2AuthorizedClient("shop") OAuth2AuthorizedClient client) {
        return orderApi.get()
                .uri("/api/orders")
                .headers(h -> h.setBearerAuth(client.getAccessToken().getTokenValue()))
                .retrieve()
                .body(String.class);
    }
}

Les deux endpoints utilisent deux types de tokens différents :

  • OidcUser est construit à partir du ID token (et UserInfo s’il existe). C’est la bonne façon de savoir qui est l’utilisateur.
  • @RegisteredOAuth2AuthorizedClient récupère l’access token de l’enregistrement shop. Si le token a expiré et qu’il y a un refresh token, Spring le renouvelle automatiquement avant de le retourner.

Appeler /me après la connexion retournera :

{"subject":"customer-42","email":"lan@example.com","name":"Nguyễn Thị Lan"}

Note : Map.of lève une NullPointerException si une valeur est null. Le claim email n’existe que si vous demandez le scope email et que l’utilisateur en a un, donc gérez toujours le cas où le claim est absent.

5. Créer un serveur de ressources qui vérifie JWT

5.1. Dépendances et configuration

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
server:
  port: 8081
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8180/realms/shop
          audiences: order-api

Avec cette configuration, Spring vérifie chaque requête par les étapes suivantes :

  • Récupère la clé publique depuis le jwks_uri de l’émetteur.
  • Vérifie la signature et exp/nbf.
  • Vérifie que iss correspond à issuer-uri.
  • Vérifie que aud contient order-api.

Oublier de vérifier l’audience est une erreur très courante. Sinon, l’API acceptera des tokens émis pour un autre service du même realm.

5.2. Autorisation selon les scopes

public record Order(String id, String customerId, BigDecimal total) {}
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {

    @Bean
    SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders.read")
                .anyRequest().authenticated())
            .oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()))
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(AbstractHttpConfigurer::disable);
        return http.build();
    }
}
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping
    public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
        return orderService.findByCustomer(jwt.getSubject());
    }
}

Par défaut, Spring convertit le claim scope (ou scp) en autorités avec le préfixe SCOPE_. Le scope orders.read devient donc SCOPE_orders.read.

Pourquoi désactiver CSRF ? Cette API est sans état (stateless) et accepte uniquement les tokens via l’en-tête Authorization, n’utilise pas de cookies. Par conséquent, désactiver CSRF est raisonnable.

Le contrôleur récupère sub du token plutôt que customerId depuis la chaîne de requête. Cela empêche un utilisateur de consulter les commandes d’une autre personne.

Les résultats avec différents types de requêtes :

Pas de token                          -> 401 Unauthorized
Token valide mais scope manquant      -> 403 Forbidden
Token valide, a orders.read           -> 200 OK + liste des commandes de l'utilisateur

Note : Keycloak place les rôles dans realm_access.roles, pas dans scope. Si vous voulez autoriser selon les rôles, déclarez un JwtAuthenticationConverter personnalisé pour mapper ce claim vers GrantedAuthority.

6. Tester le serveur de ressources

Vous n’avez pas besoin d’une vraie Keycloak pour tester. La bibliothèque spring-security-test a un post-processor jwt() pour simuler un token authentifié.

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(ResourceServerConfig.class)
class OrderControllerTest {

    @Autowired
    MockMvcTester mvc;

    @MockitoBean
    OrderService orderService;

    @MockitoBean
    JwtDecoder jwtDecoder; // évite d'appeler l'émetteur réel au démarrage du contexte

    @Test
    void returnsOrdersOfCurrentCustomer() {
        given(orderService.findByCustomer("customer-42"))
                .willReturn(List.of(new Order("A-1001", "customer-42", new BigDecimal("250000"))));

        assertThat(mvc.get().uri("/api/orders")
                .with(jwt().jwt(j -> j.subject("customer-42"))
                           .authorities(new SimpleGrantedAuthority("SCOPE_orders.read"))))
                .hasStatusOk()
                .bodyJson().extractingPath("$[0].id").isEqualTo("A-1001");
    }

    @Test
    void rejectsTokenWithoutScope() {
        assertThat(mvc.get().uri("/api/orders").with(jwt()))
                .hasStatus(HttpStatus.FORBIDDEN);
    }

    @Test
    void rejectsRequestWithoutToken() {
        assertThat(mvc.get().uri("/api/orders"))
                .hasStatus(HttpStatus.UNAUTHORIZED);
    }
}

Ces trois tests couvrent les trois branches de la section 5.2 :

  • jwt() contourne l’étape de décodage. Il place directement un JwtAuthenticationToken dans le SecurityContext, donc le test s’exécute rapidement et indépendamment.
  • Vous devez passer authorities(...) explicitement. jwt() n’a par défaut que SCOPE_read, donc le deuxième test reçoit 403.
  • MockMvcTester (Spring Boot 3.4+) permet d’écrire des assertions avec AssertJ directement sur la réponse.

7. Erreurs courantes et quand ne pas utiliser

Utiliser le ID token pour appeler une API. Le ID token a aud comme client, pas comme API. Un serveur de ressources correctement configuré le rejettera. Envoyez toujours l’access token.

Utiliser l’access token pour vous connecter. Avec OAuth 2.0 pur, l’access token peut être une chaîne opaque et ne contenir rien sur l’utilisateur. Si le client analyse lui-même l’access token JWT pour extraire l’identité, il dépend d’un format qui ne lui est pas garanti. Utilisez le ID token ou l’endpoint UserInfo.

Stocker les tokens dans localStorage sur une SPA. Une seule faille XSS suffit pour exposer tous les tokens. Un pattern plus sûr est BFF (Backend for Frontend). L’application Spring de la section 4 garde les tokens côté serveur, le navigateur n’a que le cookie de session HttpOnly.

Ne pas vérifier aud, ou laisser les access tokens vivre trop longtemps. Les JWT ne peuvent pas être révoqués avant expiration. Gardez la durée de vie de l’access token courte (5–15 minutes) et utilisez le refresh token pour prolonger.

Décalage dans issuer-uri. Si Keycloak retourne iss comme http://keycloak:8080/... (nom d’hôte dans Docker) mais vous configurez localhost:8180, tous les tokens sont rejetés avec l’erreur invalid_token. La valeur iss doit correspondre exactement.

Note : Vous n’avez pas toujours besoin d’OAuth 2.0. Si vous n’avez qu’une seule application monolithe avec une connexion par formulaire et qu’aucun tiers n’a besoin d’appeler l’API, une session standard de Spring Security est plus simple et sûre. Pour la communication machine-à-machine sans utilisateur, utilisez la subvention client_credentials, pas Authorization Code.

8. Conclusion

OAuth 2.0 répond à la question « que peut faire cette application » via l’access token. OIDC répond à la question « qui est l’utilisateur » via le ID token. Le flux à utiliser est Authorization Code + PKCE, avec state et nonce qui l’accompagnent.

Avec Spring Boot 3, vous avez vu :

  • spring-boot-starter-oauth2-client avec oauth2Login gère la totalité de la partie connexion OIDC côté client.
  • spring-boot-starter-oauth2-resource-server vérifie JWT selon issuer-uri et audiences.
  • spring-security-test permet de tester l’autorisation selon les scopes sans serveur d’autorisation réel.

Les prochaines étapes peuvent être de mapper les rôles de Keycloak avec un JwtAuthenticationConverter personnalisé, ou de diviser le client en BFF pour une SPA.

oauth2openid-connectspring-securityjwtweb-security