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 :
stateprévient la CSRF lors du callback.noncelie 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 lecode_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 :
oauth2Loginactive 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()ajoutecode_challengemême pour les clients confidentiels (clients avec secret). Les clients publics ont déjà PKCE activé par Spring par défaut.OidcClientInitiatedLogoutSuccessHandlerse 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 :
OidcUserest construit à partir du ID token (et UserInfo s’il existe). C’est la bonne façon de savoir qui est l’utilisateur.@RegisteredOAuth2AuthorizedClientrécupère l’access token de l’enregistrementshop. 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.oflève uneNullPointerExceptionsi une valeur estnull. Le claim
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_uride l’émetteur. - Vérifie la signature et
exp/nbf. - Vérifie que
isscorrespond àissuer-uri. - Vérifie que
audcontientorder-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 dansscope. Si vous voulez autoriser selon les rôles, déclarez unJwtAuthenticationConverterpersonnalisé pour mapper ce claim versGrantedAuthority.
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 unJwtAuthenticationTokendans leSecurityContext, donc le test s’exécute rapidement et indépendamment.- Vous devez passer
authorities(...)explicitement.jwt()n’a par défaut queSCOPE_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-clientavecoauth2Logingère la totalité de la partie connexion OIDC côté client.spring-boot-starter-oauth2-resource-servervérifie JWT selonissuer-urietaudiences.spring-security-testpermet 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.