# 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. - Published: 2026-03-16 - Updated: 2026-05-01 - Author: SharpSkill - Tags: spring security, oauth2, resource server, jwt, spring boot - Reading time: 14 min --- 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. > **Resource Server vs JWT personalizzato** > > 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. ```xml org.springframework.boot spring-boot-starter-oauth2-resource-server org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-validation ``` 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. ```yaml # 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/myrealm ``` A 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. ```java // SecurityConfig.java @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. > **Avvio senza IdP** > > 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. ```yaml # 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 - account ``` Questo 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`. ```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() ) // 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. ## 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. ```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 realm and client roles Collection 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 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(); } // 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 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()); } // 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`. ```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 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. ```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<>(); // 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 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`. ```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() { // Create decoder with JWKS URL NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder .withJwkSetUri(jwkSetUri) .build(); // Combine default validators with custom validator OAuth2TokenValidator defaultValidators = JwtValidators.createDefaultWithIssuer(issuerUri); OAuth2TokenValidator combinedValidator = new DelegatingOAuth2TokenValidator<>(defaultValidators, customValidator); jwtDecoder.setJwtValidator(combinedValidator); return jwtDecoder; } } ``` > **Performance della validazione** > > 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. ```java // UserController.java @RestController @RequestMapping("/api/users") public class UserController { // Direct JWT injection 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 with JwtAuthenticationToken to access authorities @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 )); } // Access scopes for conditional business logic @GetMapping("/data") @PreAuthorize("hasAuthority('SCOPE_read')") public ResponseEntity 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 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'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. ```java // OAuth2SecurityExceptionHandler.java @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 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. ```yaml # 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: WARN ``` In 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. ```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'utility `jwt()` di `spring-security-test` crea token simulati senza richiedere un Authorization Server. ```java // SecurityTestConfig.java @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(); }; } } ``` ## 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-uri` o `jwk-set-uri` in 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. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/it/blog/spring-boot/spring-security-6-oauth2-resource-server