Spring Security 6: Configurazione OAuth2 Resource Server
Guida pratica alla configurazione di un OAuth2 Resource Server con Spring Security 6. Validazione JWT, configurazione issuer, gestione degli scope e integrazione con Keycloak.

Un OAuth2 Resource Server protegge le API validando i token JWT emessi da un Authorization Server esterno come Keycloak, Auth0 o Okta. A differenza dell'autenticazione JWT personalizzata in cui l'applicazione genera i propri token, il Resource Server delega completamente la gestione dell'identità a un Identity Provider (IdP) dedicato.
Un Resource Server non genera mai token. Si limita a validarli. Questa separazione delle responsabilità rafforza la sicurezza e semplifica l'architettura centralizzando la gestione dell'identità.
Architettura dell'OAuth2 Resource Server
Il flusso OAuth2 coinvolge tre attori principali. Il client (applicazione frontend o mobile) ottiene un access token dall'Authorization Server. In seguito include questo token in ogni richiesta al Resource Server. Il Resource Server valida il token verificandone la firma tramite la chiave pubblica dell'Authorization Server.
Questa architettura offre diversi vantaggi. Il Resource Server rimane stateless poiché tutte le informazioni necessarie sono contenute nel token JWT. La validazione avviene senza chiamate di rete all'IdP grazie alla verifica della firma asimmetrica. Più API possono condividere lo stesso Authorization Server, semplificando la gestione degli utenti.
┌──────────────┐ 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) │
└──────────────────────┘ └─────────────────────┘Il Resource Server scarica le chiavi pubbliche all'avvio e le memorizza in cache, evitando chiamate di rete per ogni richiesta.
Dipendenze Maven per OAuth2 Resource Server
La configurazione di un Resource Server richiede due dipendenze specifiche. Lo starter oauth2-resource-server fornisce l'infrastruttura di base, mentre oauth2-jose contiene le classi per decodificare e validare i JWT.
<!-- pom.xml -->
<dependencies>
<!-- OAuth2 Resource Server support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- Spring Web for REST endpoints -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- DTO validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>Non sono necessarie dipendenze aggiuntive come JJWT. Spring Security utilizza la libreria Nimbus JOSE+JWT inclusa in spring-security-oauth2-jose per la gestione dei token.
Configurazione di base con issuer-uri
La configurazione minima del Resource Server richiede solo due righe. Spring Security scopre automaticamente gli endpoint dell'IdP tramite il protocollo OpenID Connect Discovery.
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
# URI dell'Authorization Server
# Spring recupera automaticamente le chiavi pubbliche tramite /.well-known/openid-configuration
issuer-uri: https://keycloak.example.com/realms/myrealmA partire da issuer-uri, Spring Security effettua una richiesta a {issuer-uri}/.well-known/openid-configuration per ottenere i metadati dell'Authorization Server. Successivamente estrae l'URL del JWKS (JSON Web Key Set) contenente le chiavi pubbliche utilizzate per validare le firme dei token.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
// Disable CSRF for stateless APIs
.csrf(csrf -> csrf.disable())
// Authorization rules
.authorizeHttpRequests(auth -> auth
// Public endpoints
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
// All other requests require a valid token
.anyRequest().authenticated()
)
// Enable OAuth2 JWT validation
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
)
// Stateless mode required for Resource Servers
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.build();
}
}Questa configurazione è sufficiente per proteggere un'API. Spring Security valida automaticamente la firma del token, verifica i timestamp (exp, nbf, iat) e si assicura che il claim iss corrisponda all'issuer-uri configurato.
Per impostazione predefinita, l'applicazione non si avvia se l'Authorization Server non è raggiungibile. Per consentire un avvio indipendente, è necessario configurare jwk-set-uri esplicitamente al posto di issuer-uri.
Configurazione avanzata con jwk-set-uri
Quando l'Authorization Server non supporta OpenID Connect Discovery o quando l'applicazione deve avviarsi senza dipendenza di rete dall'IdP, la configurazione esplicita del JWKS risulta preferibile.
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
# Direct URL to JWKS (public keys)
jwk-set-uri: https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs
# Expected value of the "iss" claim in tokens
issuer-uri: https://keycloak.example.com/realms/myrealm
# Allowed audiences ("aud" claim)
audiences:
- my-api
- accountQuesto approccio offre un maggiore controllo. La validazione dell'audience impedisce l'utilizzo di token destinati ad altri servizi. Un token emesso per l'applicazione frontend-app verrà rifiutato se l'API accetta solo le audience my-api e 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()
)
// Custom JWT configuration
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.build();
}
// Custom converter to extract authorities from the token
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
// SCOPE_ prefix for authorities (e.g., SCOPE_read, SCOPE_write)
grantedAuthoritiesConverter.setAuthorityPrefix("SCOPE_");
// Claim containing scopes (OAuth2 standard)
grantedAuthoritiesConverter.setAuthoritiesClaimName("scope");
JwtAuthenticationConverter jwtAuthenticationConverter =
new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(
grantedAuthoritiesConverter
);
return jwtAuthenticationConverter;
}
}Il JwtAuthenticationConverter trasforma i claim del token in oggetti GrantedAuthority utilizzabili nelle espressioni di sicurezza.
Pronto a superare i tuoi colloqui su Spring Boot?
Pratica con i nostri simulatori interattivi, flashcards e test tecnici.
Estrazione dei ruoli Keycloak
Keycloak struttura i ruoli in modo diverso dallo standard OAuth2. I ruoli si trovano in realm_access.roles (ruoli del realm) o resource_access.{client}.roles (ruoli specifici del client). Un converter personalizzato estrae queste informazioni.
@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 realm and client roles
Collection<GrantedAuthority> authorities = Stream.concat(
extractRealmRoles(jwt).stream(),
extractClientRoles(jwt).stream()
).collect(Collectors.toSet());
// Return authentication token with extracted authorities
return new JwtAuthenticationToken(jwt, authorities, extractUsername(jwt));
}
// Extract realm-level roles
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();
}
// ROLE_ prefix for compatibility with hasRole()
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toList());
}
// Extract client-specific roles
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());
}
// Extract username from token
private String extractUsername(Jwt jwt) {
// Keycloak uses "preferred_username" by default
String username = jwt.getClaimAsString("preferred_username");
if (username != null) {
return username;
}
// Fallback to subject (usually the user UUID)
return jwt.getSubject();
}
}L'integrazione nella configurazione di sicurezza avviene tramite il parametro 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 by Keycloak role
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
// Use Keycloak converter
.jwtAuthenticationConverter(keycloakConverter)
)
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.build();
}
}I ruoli estratti sono ora utilizzabili con annotazioni @PreAuthorize("hasRole('ADMIN')") o nelle espressioni requestMatchers().hasRole().
Validazione personalizzata dei token
Validazioni aggiuntive possono essere richieste in base alle esigenze di business: verifica di claim personalizzati, validazione dell'audience o controllo della durata massima.
@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<>();
// Audience validation
if (!validateAudience(jwt)) {
errors.add(AUDIENCE_ERROR);
}
// Custom business claim validation
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) {
// Check for required business claim
String tenantId = jwt.getClaimAsString(tenantClaimName);
return tenantId != null && !tenantId.isBlank();
}
}Il validatore personalizzato si integra nella configurazione del 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() {
// Create decoder with JWKS URL
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder
.withJwkSetUri(jwkSetUri)
.build();
// Combine default validators with custom validator
OAuth2TokenValidator<Jwt> defaultValidators =
JwtValidators.createDefaultWithIssuer(issuerUri);
OAuth2TokenValidator<Jwt> combinedValidator =
new DelegatingOAuth2TokenValidator<>(defaultValidators, customValidator);
jwtDecoder.setJwtValidator(combinedValidator);
return jwtDecoder;
}
}Le validazioni sincrone bloccano il thread di elaborazione. Per validazioni che richiedono chiamate esterne (database, servizi di terze parti), è preferibile un filtro asincrono o una verifica successiva.
Accesso alle informazioni del token nei controller
Spring Security fornisce diversi metodi per accedere alle informazioni del token JWT nei controller.
@RestController
@RequestMapping("/api/users")
public class UserController {
// Direct JWT injection 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 with JwtAuthenticationToken to access authorities
@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
));
}
// Access scopes for conditional business logic
@GetMapping("/data")
@PreAuthorize("hasAuthority('SCOPE_read')")
public ResponseEntity<DataResponse> getData(
@AuthenticationPrincipal Jwt jwt
) {
boolean canWrite = hasScope(jwt, "write");
// Business logic adapted according to 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'annotazione @AuthenticationPrincipal evita di recuperare manualmente l'autenticazione dal SecurityContextHolder.
Gestione degli errori di autenticazione OAuth2
Una gestione specifica degli errori migliora l'esperienza dello sviluppatore per i consumatori dell'API.
@RestControllerAdvice
public class OAuth2SecurityExceptionHandler {
private static final Logger log =
LoggerFactory.getLogger(OAuth2SecurityExceptionHandler.class);
// Missing token or invalid format
@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())
);
}
// Access denied despite valid token
@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
);
}
// OAuth2-specific error (expired token, invalid signature, 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
) {}I codici di errore standardizzati di OAuth2 (invalid_token, insufficient_scope) facilitano l'elaborazione lato client.
Configurazione multi-ambiente
La configurazione varia in base all'ambiente. In sviluppo è possibile utilizzare un server Keycloak locale, mentre in produzione un IdP gestito come Auth0 prende il sopravvento.
# application.yml (common configuration)
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: WARNIn produzione, i valori sensibili provengono da variabili d'ambiente o da un secrets manager.
Test di integrazione con JWT simulati
I test di integrazione utilizzano le utility di Spring Security Test per simulare token JWT validi.
@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'utility jwt() di spring-security-test crea token simulati senza richiedere un Authorization Server.
@TestConfiguration
public class SecurityTestConfig {
// Mocked JwtDecoder for tests
@Bean
@Primary
public JwtDecoder jwtDecoder() {
return token -> {
// Return a mocked JWT for tests
return Jwt.withTokenValue(token)
.header("alg", "RS256")
.subject("test-user")
.claim("scope", "read write")
.build();
};
}
}Inizia a praticare!
Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.
Conclusione
La configurazione di un OAuth2 Resource Server con Spring Security 6 centralizza la validazione dei token e semplifica la sicurezza delle API. L'Authorization Server gestisce l'autenticazione e l'emissione dei token, mentre l'API si concentra sulla validazione e sull'estrazione delle informazioni di identità.
Checklist di deployment:
- ✅ Configurare
issuer-uriojwk-set-uriin base alle esigenze - ✅ Validare l'audience per evitare l'uso improprio di token di altri servizi
- ✅ Converter personalizzato per estrarre i ruoli Keycloak
- ✅ Gestione degli errori OAuth2 con codici standardizzati
- ✅ Test di integrazione con token simulati
- ✅ HTTPS obbligatorio tra client e Resource Server
- ✅ Configurazione specifica per ambiente
- ✅ Logging dei tentativi di autenticazione falliti
Questa architettura si integra naturalmente in un ecosistema di microservizi in cui più API condividono lo stesso Identity Provider.
Tag
Condividi
Articoli correlati

Spring Security 6: Autenticazione JWT Completa
Guida pratica per implementare l'autenticazione JWT con Spring Security 6: configurazione, generazione di token, validazione e best practice di sicurezza.

Spring Modulith: Architettura del Monolite Modulare Spiegata
Impara Spring Modulith per costruire monoliti modulari in Java. Architettura, moduli, eventi asincroni e testing con esempi Spring Boot 3.

Colloquio Spring Batch 5: Partitioning, Chunk e Fault Tolerance
Padroneggia i colloqui Spring Batch 5: 15 domande essenziali su partitioning, elaborazione a chunk e fault tolerance con esempi in Java 21.