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.

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é.
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.
<!-- pom.xml -->
<dependencies>
<!-- Support OAuth2 Resource Server -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- Spring Web pour les endpoints REST -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Validation des DTOs -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>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.
# 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.
@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é.
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.
# 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
- accountCette 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.
@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é.
Prêt à réussir tes entretiens Spring Boot ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
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.
@Component
public class KeycloakJwtAuthenticationConverter
implements Converter<Jwt, AbstractAuthenticationToken> {
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<GrantedAuthority> 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<GrantedAuthority> extractRealmRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaim(REALM_ACCESS_CLAIM);
if (realmAccess == null) {
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) 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<GrantedAuthority> extractClientRoles(Jwt jwt) {
Map<String, Object> resourceAccess = jwt.getClaim(RESOURCE_ACCESS_CLAIM);
if (resourceAccess == null) {
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
Map<String, Object> clientAccess =
(Map<String, Object>) resourceAccess.get(clientId);
if (clientAccess == null) {
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) 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.
@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.
@Component
public class CustomJwtValidator implements OAuth2TokenValidator<Jwt> {
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<OAuth2Error> 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<String> 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.
@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<Jwt> defaultValidators =
JwtValidators.createDefaultWithIssuer(issuerUri);
OAuth2TokenValidator<Jwt> combinedValidator =
new DelegatingOAuth2TokenValidator<>(defaultValidators, customValidator);
jwtDecoder.setJwtValidator(combinedValidator);
return jwtDecoder;
}
}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.
@RestController
@RequestMapping("/api/users")
public class UserController {
// Injection directe du JWT via @AuthenticationPrincipal
@GetMapping("/me")
public ResponseEntity<UserInfoResponse> getCurrentUser(
@AuthenticationPrincipal Jwt jwt
) {
String userId = jwt.getSubject();
String email = jwt.getClaimAsString("email");
String username = jwt.getClaimAsString("preferred_username");
List<String> 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<ProfileResponse> getProfile(
JwtAuthenticationToken authentication
) {
Jwt jwt = authentication.getToken();
Collection<String> 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<DataResponse> 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<String> extractRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess == null) {
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) 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);
}
}public record UserInfoResponse(
String userId,
String email,
String username,
List<String> roles
) {}
// ProfileResponse.java
public record ProfileResponse(
String userId,
String name,
Collection<String> 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.
@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<String, String> 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.
# 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: WARNEn 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.
@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.
@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();
};
}
}Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
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-urioujwk-set-uriselon 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.
Tags
Partager
Articles similaires

Spring Security 6 : Authentification JWT complète
Guide pratique pour implémenter une authentification JWT avec Spring Security 6. Configuration, génération de tokens, validation et bonnes pratiques.

Spring Modulith : architecture modulaire monolithique expliquée
Découvrez Spring Modulith pour construire des monolithes modulaires en Java. Architecture, modules, événements asynchrones et tests avec exemples Spring Boot 3.

Spring Batch 5 en entretien technique : partitioning, chunks et fault tolerance
Préparez vos entretiens Spring Batch 5 : 15 questions essentielles sur le partitioning, chunk-oriented processing, fault tolerance avec exemples de code Java 21.