karawaci.kode

← Semua snippet

Java Lanjut Auth

Spring OAuth2 Resource Server JWT setup

Setup Spring Boot sebagai OAuth2 Resource Server — verifikasi JWT dari Keycloak / Auth0 / IDP internal, role-based access control.

Dipublikasikan 7 Juli 2026

Spring Boot legacy biasanya pakai filter custom + parse JWT manual — repot dan rawan bug. Spring Security 6 punya OAuth2 Resource Server module yang clean. Tinggal config issuer URI, JWT divalidasi otomatis. Snippet ini setup lengkap dengan role mapping dari Keycloak realm role.

Kode

// pom.xml dependencies
// <dependency>
//   <groupId>org.springframework.boot</groupId>
//   <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
// </dependency>
// <dependency>
//   <groupId>org.springframework.boot</groupId>
//   <artifactId>spring-boot-starter-security</artifactId>
// </dependency>

// SecurityConfig.java
package id.kodekarawaci.config;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // Disable CSRF — JWT bearer tidak vulnerable CSRF
            .csrf(AbstractHttpConfigurer::disable)

            // Stateless session — token di header, tidak ada server session
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/internal/**").hasAuthority("SCOPE_internal")
                .anyRequest().authenticated()
            )

            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
            );

        return http.build();
    }

    /**
     * Convert JWT claim -> Spring authority.
     * Default Spring map "scope" claim. Kita extend untuk:
     * - realm_access.roles (Keycloak realm role)
     * - resource_access.{client_id}.roles (Keycloak client role)
     */
    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter scopeConverter = new JwtGrantedAuthoritiesConverter();
        // Default prefix "SCOPE_" untuk scope claim

        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwt -> {
            // Merge scope authorities + realm role authorities
            Collection<GrantedAuthority> authorities = scopeConverter.convert(jwt);
            return Stream.concat(
                authorities.stream(),
                extractKeycloakRoles(jwt).stream()
            ).toList();
        });

        // Subject jadi principal name (default sub claim)
        converter.setPrincipalClaimName("preferred_username");

        return converter;
    }

    @SuppressWarnings("unchecked")
    private List<GrantedAuthority> extractKeycloakRoles(Jwt jwt) {
        // realm_access.roles -> ROLE_xxx
        Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
        if (realmAccess == null) return List.of();

        Object roles = realmAccess.get("roles");
        if (!(roles instanceof List<?> list)) return List.of();

        return list.stream()
            .filter(String.class::isInstance)
            .map(String.class::cast)
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
            .map(GrantedAuthority.class::cast)
            .toList();
    }
}
# application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # Keycloak realm — issuer untuk auto-config JWK
          issuer-uri: https://auth.tangerang.id/realms/tokopedia
          # Override jwk-set-uri kalau tidak pakai auto-discovery
          # jwk-set-uri: https://auth.tangerang.id/realms/tokopedia/protocol/openid-connect/certs

  jwt:
    audience: api-tokopedia  # Optional: validate aud claim manual

# Cache JWK 1 jam (default 5 menit) — kurangi load ke Authorization Server
spring.security.oauth2.resourceserver.jwt.cache-duration: 1h
// Sample controller dengan method security
package id.kodekarawaci.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api")
public class SampleController {

    /**
     * Setiap user authenticated bisa akses.
     * Subject (sub claim) di-inject sebagai principal.
     */
    @GetMapping("/me")
    public Object me(@AuthenticationPrincipal Jwt jwt) {
        return new MeResponse(
            jwt.getSubject(),
            jwt.getClaimAsString("email"),
            jwt.getClaimAsString("name"),
            jwt.getClaimAsStringList("groups")
        );
    }

    /**
     * Hanya user dengan role ADMIN (mapped dari realm_access.roles "admin").
     */
    @PreAuthorize("hasRole('ADMIN')")
    @DeleteMapping("/produk/{id}")
    public void hapusProduk(@PathVariable Long id) {
        // ...
    }

    /**
     * Hanya pemilik resource atau admin.
     */
    @PreAuthorize("hasRole('ADMIN') or #userId == authentication.name")
    @GetMapping("/user/{userId}/order")
    public Object userOrders(@PathVariable String userId) {
        return List.of();
    }

    record MeResponse(String sub, String email, String name, List<String> groups) {}
}

Pemakaian

# Get token dari Keycloak (test)
TOKEN=$(curl -s -X POST https://auth.tangerang.id/realms/tokopedia/protocol/openid-connect/token \
  -d "grant_type=password" \
  -d "client_id=api-tokopedia" \
  -d "[email protected]" \
  -d "password=secret123" \
  | jq -r '.access_token')

# Call API dengan token
curl http://localhost:8080/api/me \
  -H "Authorization: Bearer $TOKEN"

# Output:
# {"sub":"f47ac10b-...","email":"[email protected]","name":"Asti Larasati","groups":["koperasi-12"]}
// Test slice untuk security config
@WebMvcTest(SampleController.class)
@Import(SecurityConfig.class)
class SampleControllerTest {

    @Autowired MockMvc mvc;

    @Test
    void publicEndpoint_tanpaToken_returns200() throws Exception {
        mvc.perform(get("/actuator/health")).andExpect(status().isOk());
    }

    @Test
    void protectedEndpoint_tanpaToken_returns401() throws Exception {
        mvc.perform(get("/api/me")).andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockJwtAuth(authorities = {"ROLE_USER"}, claims = @OpenIdClaims(sub = "user-123"))
    void protectedEndpoint_denganToken_returns200() throws Exception {
        mvc.perform(get("/api/me")).andExpect(status().isOk());
    }
}

Kapan dipakai

  • API microservice dengan SSO terpusat (Keycloak, Auth0, Azure AD).
  • Multi-tenant B2B yang user authenticate via enterprise IDP.
  • API gateway pattern — token issued by central auth, validated di setiap service.
  • Compliance regulated (banking, healthcare) yang butuh standardized auth.

Catatan

  • issuer-uri auto-discover JWK endpoint, signing algorithm, dll dari .well-known/openid-configuration. Lebih simple dari config manual.
  • cache-duration JWK — production set 1 jam. Auth server downtime tidak langsung impact resource server.
  • JwtGrantedAuthoritiesConverter default map “scope” claim. Extend untuk role di claim custom (Keycloak realm_access).
  • PreAuthorize SpELauthentication.name adalah subject. principal adalah Jwt object lengkap, bisa akses claim apa pun.
  • CORS — kalau API call dari browser, set CORS config terpisah. CSRF disable OK karena bearer token.
  • Audience validation — tambahkan OAuth2TokenValidator<Jwt> custom untuk verify aud claim. Token issued untuk service lain tidak boleh accepted.

Refresh token flow di-handle client (frontend/mobile), bukan Resource Server. Resource Server cuma validate access token. Kalau access expired, client refresh dan retry.

# tags

springoauth2jwtauthresource-server

Ditulis oleh Asti Larasati · 7 Juli 2026