# Spring Security 6 : configurer un serveur de ressources OAuth2 > Guide pratique pour configurer un Resource Server OAuth2 avec Spring Security 6. Validation JWT, configuration issuer, gestion des scopes et intégration Keycloak. - Published: 2026-03-16 - Updated: 2026-03-31 - Author: SharpSkill - Tags: spring security, oauth2, resource server, jwt, spring boot - Reading time: 14 min --- Un serveur de ressources OAuth2 protège les API en validant les tokens JWT émis par un serveur d'autorisation externe comme Keycloak, Auth0 ou Okta. Contrairement à l'authentification JWT personnalisée où l'application génère ses propres tokens, le Resource Server délègue entièrement la gestion des identités à un Identity Provider (IdP) dédié. > **Resource Server vs JWT custom** > > Un Resource Server ne génère jamais de tokens. Il les valide uniquement. Cette séparation des responsabilités renforce la sécurité et simplifie l'architecture en centralisant la gestion des identités. ## Architecture OAuth2 Resource Server Le flux OAuth2 implique trois acteurs principaux. Le client (application frontend ou mobile) obtient un token d'accès auprès du serveur d'autorisation. Il inclut ensuite ce token dans chaque requête vers le Resource Server. Le Resource Server valide le token en vérifiant sa signature avec la clé publique du serveur d'autorisation. Cette architecture présente plusieurs avantages. Le Resource Server reste stateless car toutes les informations nécessaires sont contenues dans le token JWT. La validation s'effectue sans appel réseau vers l'IdP grâce au mécanisme de signature asymétrique. Plusieurs API peuvent partager le même serveur d'autorisation, simplifiant la gestion des utilisateurs. ``` ┌──────────────┐ 1. Login ┌─────────────────────┐ │ Client │ ───────────────► │ Authorization │ │ (SPA/App) │ │ Server (Keycloak) │ │ │ ◄─────────────────│ │ └──────────────┘ 2. JWT Token └─────────────────────┘ │ │ │ 3. Request + Bearer Token │ ▼ ▼ ┌──────────────────────┐ ┌─────────────────────┐ │ Resource Server │ ◄─────── │ JWKS Endpoint │ │ (Spring Boot API) │ 4. Public Keys (cached) │ └──────────────────────┘ └─────────────────────┘ ``` Le Resource Server télécharge les clés publiques au démarrage et les met en cache, évitant ainsi les appels réseau à chaque requête. ## Dépendances Maven pour OAuth2 Resource Server La configuration d'un Resource Server nécessite deux dépendances spécifiques. Le starter `oauth2-resource-server` fournit l'infrastructure de base, tandis que `oauth2-jose` contient les classes pour décoder et valider les JWT. ```xml org.springframework.boot spring-boot-starter-oauth2-resource-server org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-validation ``` Aucune dépendance supplémentaire comme JJWT n'est nécessaire. Spring Security utilise la bibliothèque Nimbus JOSE+JWT incluse dans `spring-security-oauth2-jose` pour la manipulation des tokens. ## Configuration de base avec issuer-uri La configuration minimale d'un Resource Server tient en deux lignes. Spring Security découvre automatiquement les endpoints de l'IdP via le protocole OpenID Connect Discovery. ```yaml # application.yml spring: security: oauth2: resourceserver: jwt: # URI du serveur d'autorisation # Spring récupère automatiquement les clés publiques via /.well-known/openid-configuration issuer-uri: https://keycloak.example.com/realms/myrealm ``` À partir de l'`issuer-uri`, Spring Security effectue une requête vers `{issuer-uri}/.well-known/openid-configuration` pour obtenir les métadonnées du serveur d'autorisation. Il extrait ensuite l'URL du JWKS (JSON Web Key Set) contenant les clés publiques utilisées pour valider les signatures des tokens. ```java // SecurityConfig.java @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http // Désactive CSRF pour les API stateless .csrf(csrf -> csrf.disable()) // Règles d'autorisation .authorizeHttpRequests(auth -> auth // Endpoints publics .requestMatchers("/api/public/**").permitAll() .requestMatchers("/actuator/health").permitAll() // Toutes les autres requêtes nécessitent un token valide .anyRequest().authenticated() ) // Active la validation JWT OAuth2 .oauth2ResourceServer(oauth2 -> oauth2 .jwt(Customizer.withDefaults()) ) // Mode stateless obligatoire pour les Resource Servers .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .build(); } } ``` Cette configuration suffit pour protéger une API. Spring Security valide automatiquement la signature du token, vérifie les timestamps (`exp`, `nbf`, `iat`) et contrôle que le claim `iss` correspond à l'`issuer-uri` configuré. > **Démarrage sans IdP** > > Par défaut, l'application échoue au démarrage si le serveur d'autorisation n'est pas accessible. Pour permettre un démarrage indépendant, configurez `jwk-set-uri` explicitement au lieu de `issuer-uri`. ## Configuration avancée avec jwk-set-uri Lorsque le serveur d'autorisation ne supporte pas OpenID Connect Discovery ou que l'application doit démarrer sans dépendance réseau vers l'IdP, la configuration explicite du JWKS est préférable. ```yaml # application.yml spring: security: oauth2: resourceserver: jwt: # URL directe vers le JWKS (clés publiques) jwk-set-uri: https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs # Valeur attendue du claim "iss" dans les tokens issuer-uri: https://keycloak.example.com/realms/myrealm # Audiences autorisées (claim "aud") audiences: - my-api - account ``` Cette approche offre plus de contrôle. La validation de l'audience empêche l'utilisation de tokens destinés à d'autres services. Un token émis pour l'application `frontend-app` sera rejeté si l'API n'accepte que les audiences `my-api` et `account`. ```java // SecurityConfig.java @Configuration @EnableWebSecurity public class SecurityConfig { @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") private String issuerUri; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated() ) // Configuration JWT personnalisée .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .jwtAuthenticationConverter(jwtAuthenticationConverter()) ) ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .build(); } // Convertisseur personnalisé pour extraire les autorités du token @Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); // Préfixe SCOPE_ pour les autorités (ex: SCOPE_read, SCOPE_write) grantedAuthoritiesConverter.setAuthorityPrefix("SCOPE_"); // Claim contenant les scopes (standard OAuth2) grantedAuthoritiesConverter.setAuthoritiesClaimName("scope"); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter( grantedAuthoritiesConverter ); return jwtAuthenticationConverter; } } ``` Le `JwtAuthenticationConverter` transforme les claims du token en objets `GrantedAuthority` utilisables dans les expressions de sécurité. ## Extraction des rôles Keycloak Keycloak structure les rôles différemment du standard OAuth2. Les rôles peuvent se trouver dans `realm_access.roles` (rôles du realm) ou `resource_access.{client}.roles` (rôles spécifiques au client). Un convertisseur personnalisé extrait ces informations. ```java // KeycloakJwtAuthenticationConverter.java @Component public class KeycloakJwtAuthenticationConverter implements Converter { private static final String REALM_ACCESS_CLAIM = "realm_access"; private static final String RESOURCE_ACCESS_CLAIM = "resource_access"; private static final String ROLES_CLAIM = "roles"; @Value("${keycloak.client-id}") private String clientId; @Override public AbstractAuthenticationToken convert(Jwt jwt) { // Combine les rôles du realm et du client Collection authorities = Stream.concat( extractRealmRoles(jwt).stream(), extractClientRoles(jwt).stream() ).collect(Collectors.toSet()); // Retourne un token d'authentification avec les autorités extraites return new JwtAuthenticationToken(jwt, authorities, extractUsername(jwt)); } // Extrait les rôles au niveau du realm private Collection extractRealmRoles(Jwt jwt) { Map realmAccess = jwt.getClaim(REALM_ACCESS_CLAIM); if (realmAccess == null) { return Collections.emptyList(); } @SuppressWarnings("unchecked") List roles = (List) realmAccess.get(ROLES_CLAIM); if (roles == null) { return Collections.emptyList(); } // Préfixe ROLE_ pour compatibilité avec hasRole() return roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())) .collect(Collectors.toList()); } // Extrait les rôles spécifiques au client private Collection extractClientRoles(Jwt jwt) { Map resourceAccess = jwt.getClaim(RESOURCE_ACCESS_CLAIM); if (resourceAccess == null) { return Collections.emptyList(); } @SuppressWarnings("unchecked") Map clientAccess = (Map) resourceAccess.get(clientId); if (clientAccess == null) { return Collections.emptyList(); } @SuppressWarnings("unchecked") List roles = (List) clientAccess.get(ROLES_CLAIM); if (roles == null) { return Collections.emptyList(); } return roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())) .collect(Collectors.toList()); } // Extrait le nom d'utilisateur du token private String extractUsername(Jwt jwt) { // Keycloak utilise "preferred_username" par défaut String username = jwt.getClaimAsString("preferred_username"); if (username != null) { return username; } // Fallback sur le subject (généralement l'UUID de l'utilisateur) return jwt.getSubject(); } } ``` L'intégration dans la configuration de sécurité s'effectue via le paramètre `jwtAuthenticationConverter`. ```java // SecurityConfig.java @Configuration @EnableWebSecurity @EnableMethodSecurity @RequiredArgsConstructor public class SecurityConfig { private final KeycloakJwtAuthenticationConverter keycloakConverter; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() // Protection par rôle Keycloak .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt // Utilise le convertisseur Keycloak .jwtAuthenticationConverter(keycloakConverter) ) ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .build(); } } ``` Les rôles extraits sont maintenant utilisables avec les annotations `@PreAuthorize("hasRole('ADMIN')")` ou dans les expressions `requestMatchers().hasRole()`. ## Validation personnalisée des tokens Des validations supplémentaires peuvent être nécessaires selon les besoins métier : vérification de claims personnalisés, validation de l'audience, ou contrôle de la durée de vie maximale. ```java // CustomJwtValidator.java @Component public class CustomJwtValidator implements OAuth2TokenValidator { private static final OAuth2Error AUDIENCE_ERROR = new OAuth2Error("invalid_token", "Token audience is not valid", null); private static final OAuth2Error CUSTOM_CLAIM_ERROR = new OAuth2Error("invalid_token", "Required custom claim missing", null); @Value("${app.jwt.required-audience}") private String requiredAudience; @Value("${app.jwt.required-tenant-claim:tenant_id}") private String tenantClaimName; @Override public OAuth2TokenValidatorResult validate(Jwt jwt) { List errors = new ArrayList<>(); // Validation de l'audience if (!validateAudience(jwt)) { errors.add(AUDIENCE_ERROR); } // Validation d'un claim métier personnalisé if (!validateTenantClaim(jwt)) { errors.add(CUSTOM_CLAIM_ERROR); } if (!errors.isEmpty()) { return OAuth2TokenValidatorResult.failure(errors); } return OAuth2TokenValidatorResult.success(); } private boolean validateAudience(Jwt jwt) { List audiences = jwt.getAudience(); return audiences != null && audiences.contains(requiredAudience); } private boolean validateTenantClaim(Jwt jwt) { // Vérifie la présence d'un claim métier requis String tenantId = jwt.getClaimAsString(tenantClaimName); return tenantId != null && !tenantId.isBlank(); } } ``` Le validateur personnalisé s'intègre dans la configuration du `JwtDecoder`. ```java // JwtConfig.java @Configuration @RequiredArgsConstructor public class JwtConfig { private final CustomJwtValidator customValidator; @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") private String issuerUri; @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") private String jwkSetUri; @Bean public JwtDecoder jwtDecoder() { // Crée le décodeur avec l'URL du JWKS NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder .withJwkSetUri(jwkSetUri) .build(); // Combine les validateurs par défaut avec le validateur personnalisé OAuth2TokenValidator defaultValidators = JwtValidators.createDefaultWithIssuer(issuerUri); OAuth2TokenValidator combinedValidator = new DelegatingOAuth2TokenValidator<>(defaultValidators, customValidator); jwtDecoder.setJwtValidator(combinedValidator); return jwtDecoder; } } ``` > **Performance des validations** > > Les validations synchrones bloquent le thread de traitement. Pour des validations nécessitant des appels externes (base de données, services tiers), préférez un filtre asynchrone ou une vérification en aval. ## Accès aux informations du token dans les contrôleurs Spring Security fournit plusieurs méthodes pour accéder aux informations du token JWT dans les contrôleurs. ```java // UserController.java @RestController @RequestMapping("/api/users") public class UserController { // Injection directe du JWT via @AuthenticationPrincipal @GetMapping("/me") public ResponseEntity getCurrentUser( @AuthenticationPrincipal Jwt jwt ) { String userId = jwt.getSubject(); String email = jwt.getClaimAsString("email"); String username = jwt.getClaimAsString("preferred_username"); List roles = extractRoles(jwt); return ResponseEntity.ok(new UserInfoResponse( userId, email, username, roles )); } // Alternative avec JwtAuthenticationToken pour accéder aux autorités @GetMapping("/profile") public ResponseEntity getProfile( JwtAuthenticationToken authentication ) { Jwt jwt = authentication.getToken(); Collection authorities = authentication.getAuthorities() .stream() .map(GrantedAuthority::getAuthority) .toList(); return ResponseEntity.ok(new ProfileResponse( jwt.getSubject(), jwt.getClaimAsString("name"), authorities )); } // Accès aux scopes pour une logique métier conditionnelle @GetMapping("/data") @PreAuthorize("hasAuthority('SCOPE_read')") public ResponseEntity getData( @AuthenticationPrincipal Jwt jwt ) { boolean canWrite = hasScope(jwt, "write"); // Logique métier adaptée selon les permissions DataResponse response = buildDataResponse(jwt.getSubject(), canWrite); return ResponseEntity.ok(response); } private List extractRoles(Jwt jwt) { Map realmAccess = jwt.getClaim("realm_access"); if (realmAccess == null) { return Collections.emptyList(); } @SuppressWarnings("unchecked") List roles = (List) realmAccess.get("roles"); return roles != null ? roles : Collections.emptyList(); } private boolean hasScope(Jwt jwt, String scope) { String scopes = jwt.getClaimAsString("scope"); return scopes != null && scopes.contains(scope); } } ``` ```java // UserInfoResponse.java public record UserInfoResponse( String userId, String email, String username, List roles ) {} // ProfileResponse.java public record ProfileResponse( String userId, String name, Collection authorities ) {} ``` L'annotation `@AuthenticationPrincipal` évite de récupérer manuellement l'authentification depuis le `SecurityContextHolder`. ## Gestion des erreurs d'authentification OAuth2 Une gestion d'erreurs spécifique améliore l'expérience développeur des consommateurs de l'API. ```java // OAuth2SecurityExceptionHandler.java @RestControllerAdvice public class OAuth2SecurityExceptionHandler { private static final Logger log = LoggerFactory.getLogger(OAuth2SecurityExceptionHandler.class); // Token manquant ou format invalide @ExceptionHandler(AuthenticationException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ErrorResponse handleAuthenticationException(AuthenticationException ex) { log.warn("Authentication failed: {}", ex.getMessage()); return new ErrorResponse( "UNAUTHORIZED", "Authentication required. Provide a valid Bearer token.", Map.of("error", ex.getMessage()) ); } // Accès refusé malgré un token valide @ExceptionHandler(AccessDeniedException.class) @ResponseStatus(HttpStatus.FORBIDDEN) public ErrorResponse handleAccessDeniedException(AccessDeniedException ex) { log.warn("Access denied: {}", ex.getMessage()); return new ErrorResponse( "FORBIDDEN", "Insufficient permissions for this resource", null ); } // Erreur spécifique OAuth2 (token expiré, signature invalide, etc.) @ExceptionHandler(OAuth2AuthenticationException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ErrorResponse handleOAuth2Exception(OAuth2AuthenticationException ex) { OAuth2Error error = ex.getError(); log.warn("OAuth2 authentication error: {} - {}", error.getErrorCode(), error.getDescription()); String message = switch (error.getErrorCode()) { case "invalid_token" -> "Token is invalid or expired"; case "insufficient_scope" -> "Token does not have required scopes"; default -> "Authentication failed"; }; return new ErrorResponse( error.getErrorCode().toUpperCase(), message, Map.of("details", error.getDescription()) ); } } // ErrorResponse.java public record ErrorResponse( String code, String message, Map details ) {} ``` Les codes d'erreur OAuth2 standardisés (`invalid_token`, `insufficient_scope`) facilitent le traitement côté client. ## Configuration pour environnements multiples La configuration varie selon l'environnement. En développement, un serveur Keycloak local peut être utilisé, tandis qu'en production, un IdP managé comme Auth0 prend le relais. ```yaml # application.yml (configuration commune) app: jwt: required-audience: my-api --- # application-dev.yml spring: config: activate: on-profile: dev security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:8180/realms/dev-realm keycloak: client-id: my-api-dev logging: level: org.springframework.security: DEBUG --- # application-prod.yml spring: config: activate: on-profile: prod security: oauth2: resourceserver: jwt: issuer-uri: ${OAUTH2_ISSUER_URI} audiences: ${OAUTH2_AUDIENCES:my-api} keycloak: client-id: ${KEYCLOAK_CLIENT_ID} logging: level: org.springframework.security: WARN ``` En production, les valeurs sensibles proviennent de variables d'environnement ou d'un gestionnaire de secrets. ## Tests d'intégration avec JWT mockés Les tests d'intégration utilisent les utilitaires de Spring Security Test pour simuler des tokens JWT valides. ```java // ResourceServerIntegrationTest.java @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @AutoConfigureMockMvc class ResourceServerIntegrationTest { @Autowired private MockMvc mockMvc; @Test void shouldRejectRequestWithoutToken() throws Exception { mockMvc.perform(get("/api/users/me")) .andExpect(status().isUnauthorized()); } @Test @WithMockJwtAuth( claims = @OpenIdClaims(sub = "user-123", preferredUsername = "john.doe"), authorities = {"ROLE_USER"} ) void shouldAllowAuthenticatedUser() throws Exception { mockMvc.perform(get("/api/users/me")) .andExpect(status().isOk()) .andExpect(jsonPath("$.userId").value("user-123")) .andExpect(jsonPath("$.username").value("john.doe")); } @Test void shouldAllowAccessWithValidJwt() throws Exception { mockMvc.perform(get("/api/users/me") .with(jwt() .jwt(jwt -> jwt .subject("user-456") .claim("preferred_username", "jane.doe") .claim("email", "jane@example.com") ) .authorities(new SimpleGrantedAuthority("ROLE_USER")) )) .andExpect(status().isOk()) .andExpect(jsonPath("$.email").value("jane@example.com")); } @Test void shouldDenyAccessWithoutAdminRole() throws Exception { mockMvc.perform(get("/api/admin/users") .with(jwt() .authorities(new SimpleGrantedAuthority("ROLE_USER")) )) .andExpect(status().isForbidden()); } @Test void shouldAllowAdminAccess() throws Exception { mockMvc.perform(get("/api/admin/users") .with(jwt() .authorities(new SimpleGrantedAuthority("ROLE_ADMIN")) )) .andExpect(status().isOk()); } } ``` L'utilitaire `jwt()` de `spring-security-test` crée des tokens mockés sans nécessiter de serveur d'autorisation. ```java // SecurityTestConfig.java @TestConfiguration public class SecurityTestConfig { // JwtDecoder mocké pour les tests @Bean @Primary public JwtDecoder jwtDecoder() { return token -> { // Retourne un JWT mocké pour les tests return Jwt.withTokenValue(token) .header("alg", "RS256") .subject("test-user") .claim("scope", "read write") .build(); }; } } ``` ## Conclusion La configuration d'un Resource Server OAuth2 avec Spring Security 6 centralise la validation des tokens et simplifie la sécurisation des API. Le serveur d'autorisation gère l'authentification et l'émission des tokens, tandis que l'API se concentre sur la validation et l'extraction des informations d'identité. **Checklist de déploiement :** - ✅ Configuration `issuer-uri` ou `jwk-set-uri` selon les besoins - ✅ Validation de l'audience pour éviter l'utilisation de tokens détournés - ✅ Convertisseur personnalisé pour extraire les rôles Keycloak - ✅ Gestion des erreurs OAuth2 avec codes standardisés - ✅ Tests d'intégration avec tokens mockés - ✅ HTTPS obligatoire entre le client et le Resource Server - ✅ Configuration différenciée par environnement - ✅ Logging des tentatives d'authentification échouées Cette architecture s'intègre naturellement dans un écosystème de microservices où plusieurs API partagent le même Identity Provider. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/spring-boot/spring-security-6-oauth2-resource-server