$npx -y skills add rrezartprebreza/spring-boot-skills --skill oauth2-resource-serverUse when configuring Spring Boot as an OAuth2 resource server, validating JWTs from an external auth provider (Keycloak, Auth0, Okta, Cognito), extracting claims, or implementing scope-based authorization.
| 1 | # OAuth2 Resource Server |
| 2 | |
| 3 | ## Dependency |
| 4 | |
| 5 | ```xml |
| 6 | <dependency> |
| 7 | <groupId>org.springframework.boot</groupId> |
| 8 | <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> |
| 9 | </dependency> |
| 10 | ``` |
| 11 | |
| 12 | ## Security Configuration |
| 13 | |
| 14 | ```java |
| 15 | @Configuration |
| 16 | @EnableWebSecurity |
| 17 | @EnableMethodSecurity |
| 18 | public class ResourceServerConfig { |
| 19 | |
| 20 | @Bean |
| 21 | public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { |
| 22 | return http |
| 23 | .csrf(AbstractHttpConfigurer::disable) |
| 24 | .sessionManagement(s -> s.sessionCreationPolicy(STATELESS)) |
| 25 | .authorizeHttpRequests(auth -> auth |
| 26 | .requestMatchers("/actuator/health").permitAll() |
| 27 | .requestMatchers("/api/v1/admin/**").hasAuthority("SCOPE_admin") |
| 28 | .anyRequest().authenticated() |
| 29 | ) |
| 30 | .oauth2ResourceServer(oauth2 -> oauth2 |
| 31 | .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter())) |
| 32 | ) |
| 33 | .build(); |
| 34 | } |
| 35 | |
| 36 | @Bean |
| 37 | public JwtAuthenticationConverter jwtAuthConverter() { |
| 38 | var converter = new JwtGrantedAuthoritiesConverter(); |
| 39 | converter.setAuthoritiesClaimName("roles"); // Keycloak uses "roles" |
| 40 | converter.setAuthorityPrefix("ROLE_"); |
| 41 | |
| 42 | var authConverter = new JwtAuthenticationConverter(); |
| 43 | authConverter.setJwtGrantedAuthoritiesConverter(converter); |
| 44 | return authConverter; |
| 45 | } |
| 46 | } |
| 47 | ``` |
| 48 | |
| 49 | ## application.yml — Common Providers |
| 50 | |
| 51 | ```yaml |
| 52 | # Keycloak |
| 53 | spring: |
| 54 | security: |
| 55 | oauth2: |
| 56 | resourceserver: |
| 57 | jwt: |
| 58 | issuer-uri: https://keycloak.example.com/realms/my-realm |
| 59 | jwk-set-uri: https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs |
| 60 | |
| 61 | # Auth0 |
| 62 | spring: |
| 63 | security: |
| 64 | oauth2: |
| 65 | resourceserver: |
| 66 | jwt: |
| 67 | issuer-uri: https://your-domain.auth0.com/ |
| 68 | audiences: https://your-api.example.com # custom claim validation |
| 69 | ``` |
| 70 | |
| 71 | ## Custom Claim Extraction |
| 72 | |
| 73 | ```java |
| 74 | @Component |
| 75 | public class JwtClaimExtractor { |
| 76 | |
| 77 | public UUID getUserId(JwtAuthenticationToken token) { |
| 78 | return UUID.fromString(token.getToken().getClaimAsString("sub")); |
| 79 | } |
| 80 | |
| 81 | public String getEmail(JwtAuthenticationToken token) { |
| 82 | return token.getToken().getClaimAsString("email"); |
| 83 | } |
| 84 | |
| 85 | public List<String> getRoles(JwtAuthenticationToken token) { |
| 86 | // Keycloak nests roles under realm_access.roles |
| 87 | Map<String, Object> realmAccess = token.getToken().getClaimAsMap("realm_access"); |
| 88 | if (realmAccess == null) return List.of(); |
| 89 | return (List<String>) realmAccess.getOrDefault("roles", List.of()); |
| 90 | } |
| 91 | } |
| 92 | ``` |
| 93 | |
| 94 | ## Controller — Accessing Current User |
| 95 | |
| 96 | ```java |
| 97 | @RestController |
| 98 | @RequiredArgsConstructor |
| 99 | public class OrderController { |
| 100 | |
| 101 | @GetMapping("/api/v1/orders/my") |
| 102 | public ApiResponse<List<OrderResponse>> myOrders( |
| 103 | @AuthenticationPrincipal Jwt jwt // inject JWT directly |
| 104 | ) { |
| 105 | UUID userId = UUID.fromString(jwt.getSubject()); |
| 106 | return ApiResponse.ok(orderService.findByUser(userId)); |
| 107 | } |
| 108 | |
| 109 | // Or with JwtAuthenticationToken for full principal |
| 110 | @GetMapping("/api/v1/profile") |
| 111 | public ApiResponse<ProfileResponse> profile(JwtAuthenticationToken token) { |
| 112 | return ApiResponse.ok(userService.findByEmail( |
| 113 | token.getToken().getClaimAsString("email") |
| 114 | )); |
| 115 | } |
| 116 | } |
| 117 | ``` |
| 118 | |
| 119 | ## Method Security with Scopes |
| 120 | |
| 121 | ```java |
| 122 | @PreAuthorize("hasAuthority('SCOPE_orders:read')") |
| 123 | public List<Order> findAll() { ... } |
| 124 | |
| 125 | @PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(#orderId, authentication)") |
| 126 | public Order findById(UUID orderId) { ... } |
| 127 | |
| 128 | // Custom security bean |
| 129 | @Component("orderSecurity") |
| 130 | public class OrderSecurityService { |
| 131 | public boolean isOwner(UUID orderId, Authentication auth) { |
| 132 | Jwt jwt = (Jwt) auth.getPrincipal(); |
| 133 | UUID userId = UUID.fromString(jwt.getSubject()); |
| 134 | return orderRepository.existsByIdAndCustomerId(orderId, userId); |
| 135 | } |
| 136 | } |
| 137 | ``` |
| 138 | |
| 139 | ## Gotchas |
| 140 | - Agent uses `hasRole("ADMIN")` for scope check — scopes use `hasAuthority("SCOPE_admin")` |
| 141 | - Agent forgets `issuer-uri` validation — always configure to prevent token forgery |
| 142 | - Agent maps roles wrong for Keycloak — roles are nested under `realm_access.roles` |
| 143 | - Agent uses `getPrincipal()` directly — cast to `Jwt` or use `@AuthenticationPrincipal Jwt` |
| 144 | - Agent adds `userDetailsService` bean — not needed for resource servers (stateless JWT) |