Spring OAuth2 Resource Server

OAuth2 Resource Server란?

Spring Security의 OAuth2 Resource Server는 JWT 또는 Opaque Token으로 보호되는 API 서버를 구축하는 모듈입니다. 인증 서버(Keycloak, Auth0, AWS Cognito 등)가 발급한 토큰을 검증하고 권한을 추출하는 역할에 집중합니다. Spring AOP 기반 메서드 시큐리티와 결합하면 세밀한 접근 제어가 가능합니다.

JWT 기반 설정

의존성 및 기본 설정

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
    implementation("org.springframework.boot:spring-boot-starter-web")
}
# application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # JWKS 엔드포인트 — 공개키 자동 로테이션
          jwk-set-uri: https://auth.example.com/.well-known/jwks.json
          # 또는 issuer-uri로 자동 디스커버리
          # issuer-uri: https://auth.example.com/realms/myapp

Security Filter Chain

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(sm -> sm.sessionCreationPolicy(STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health", "/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthConverter())
                )
            )
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
                .accessDeniedHandler(new BearerTokenAccessDeniedHandler())
            )
            .build();
    }
}

JWT → Authority 변환 커스터마이징

인증 서버마다 클레임 구조가 다릅니다. Keycloak은 realm_access.roles, Auth0는 permissions, Cognito는 cognito:groups에 역할을 넣습니다.

// Keycloak 전용 컨버터
@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
    var converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(jwt -> {
        var authorities = new ArrayList<GrantedAuthority>();

        // 1. realm_access.roles → ROLE_ 접두어
        var realmAccess = jwt.getClaimAsMap("realm_access");
        if (realmAccess != null) {
            var roles = (List<String>) realmAccess.get("roles");
            if (roles != null) {
                roles.stream()
                    .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
                    .forEach(authorities::add);
            }
        }

        // 2. resource_access.{client}.roles → 클라이언트별 역할
        var resourceAccess = jwt.getClaimAsMap("resource_access");
        if (resourceAccess != null) {
            var clientAccess = (Map<String, Object>) resourceAccess.get("my-api");
            if (clientAccess != null) {
                var clientRoles = (List<String>) clientAccess.get("roles");
                if (clientRoles != null) {
                    clientRoles.stream()
                        .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
                        .forEach(authorities::add);
                }
            }
        }

        // 3. scope → SCOPE_ 접두어 (기본 동작 유지)
        var scopes = jwt.getClaimAsStringList("scope");
        if (scopes != null) {
            scopes.stream()
                .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
                .forEach(authorities::add);
        }

        return authorities;
    });

    converter.setPrincipalClaimName("preferred_username");
    return converter;
}

커스텀 JWT Decoder — 추가 검증

@Bean
public JwtDecoder jwtDecoder() {
    var decoder = NimbusJwtDecoder
        .withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
        .jwsAlgorithm(SignatureAlgorithm.RS256)
        .build();

    // 커스텀 검증 로직 추가
    OAuth2TokenValidator<Jwt> withIssuer = JwtValidators
        .createDefaultWithIssuer("https://auth.example.com");

    // audience 검증
    OAuth2TokenValidator<Jwt> audienceValidator = token -> {
        var audiences = token.getAudience();
        if (audiences.contains("my-api")) {
            return OAuth2TokenValidatorResult.success();
        }
        return OAuth2TokenValidatorResult.failure(
            new OAuth2Error("invalid_audience", "Expected audience: my-api", null)
        );
    };

    // 조합
    var validators = new DelegatingOAuth2TokenValidator<>(
        withIssuer, audienceValidator
    );
    decoder.setJwtValidator(validators);

    return decoder;
}

메서드 시큐리티 — 세밀한 접근 제어

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
    private final OrderService orderService;

    // 역할 기반
    @GetMapping
    @PreAuthorize("hasRole('ADMIN') or hasRole('MANAGER')")
    public List<OrderDto> getAllOrders() {
        return orderService.findAll();
    }

    // 소유자 검증 — SpEL로 JWT 클레임 접근
    @GetMapping("/{id}")
    @PreAuthorize("@orderSecurity.isOwner(#id, authentication)")
    public OrderDto getOrder(@PathVariable Long id) {
        return orderService.findById(id);
    }

    // scope 기반
    @PostMapping
    @PreAuthorize("hasAuthority('SCOPE_orders:write')")
    public OrderDto createOrder(@RequestBody CreateOrderRequest request) {
        return orderService.create(request);
    }

    // 반환값 필터링
    @GetMapping("/my")
    @PostAuthorize("returnObject.customerId == authentication.token.claims['sub']")
    public OrderDto getMyLatestOrder() {
        return orderService.findLatest();
    }
}

// 커스텀 시큐리티 빈
@Component("orderSecurity")
@RequiredArgsConstructor
public class OrderSecurityEvaluator {
    private final OrderRepository orderRepo;

    public boolean isOwner(Long orderId, Authentication auth) {
        var jwt = (JwtAuthenticationToken) auth;
        var userId = jwt.getToken().getClaimAsString("sub");
        return orderRepo.findById(orderId)
            .map(order -> order.getCustomerId().equals(userId))
            .orElse(false);
    }
}

Multi-Tenancy JWT 패턴

여러 인증 서버(테넌트)의 토큰을 하나의 API에서 처리하는 패턴입니다.

@Bean
public JwtDecoder multiTenantJwtDecoder() {
    var tenantDecoders = Map.of(
        "https://tenant-a.auth.com", NimbusJwtDecoder
            .withJwkSetUri("https://tenant-a.auth.com/.well-known/jwks.json").build(),
        "https://tenant-b.auth.com", NimbusJwtDecoder
            .withJwkSetUri("https://tenant-b.auth.com/.well-known/jwks.json").build()
    );

    return token -> {
        // JWT 헤더에서 issuer 미리 파싱
        var issuer = JWTParser.parse(token).getJWTClaimsSet().getIssuer();
        var decoder = tenantDecoders.get(issuer);
        if (decoder == null) {
            throw new JwtException("Unknown issuer: " + issuer);
        }
        return decoder.decode(token);
    };
}

Opaque Token — 토큰 인트로스펙션

JWT 대신 Opaque Token을 사용하면 인증 서버에 매 요청마다 토큰 유효성을 확인합니다. 토큰 즉시 폐기가 필요한 경우에 적합합니다.

# application.yml
spring:
  security:
    oauth2:
      resourceserver:
        opaquetoken:
          introspection-uri: https://auth.example.com/oauth2/introspect
          client-id: my-api
          client-secret: ${INTROSPECTION_SECRET}

# 성능을 위해 캐싱 추가
@Bean
public OpaqueTokenIntrospector cachingIntrospector(
    @Value("${spring.security.oauth2.resourceserver.opaquetoken.introspection-uri}") String uri,
    @Value("${spring.security.oauth2.resourceserver.opaquetoken.client-id}") String clientId,
    @Value("${spring.security.oauth2.resourceserver.opaquetoken.client-secret}") String secret
) {
    var delegate = new SpringOpaqueTokenIntrospector(uri, clientId, secret);
    var cache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(Duration.ofMinutes(5))
        .build();

    return token -> cache.get(token, delegate::introspect);
}

테스트 전략

@WebMvcTest(OrderController.class)
@ImportAutoConfiguration(SecurityAutoConfiguration.class)
class OrderControllerTest {

    @Autowired MockMvc mockMvc;

    @Test
    void getOrders_withAdminRole_returns200() throws Exception {
        mockMvc.perform(get("/api/orders")
                .with(jwt()
                    .jwt(j -> j
                        .claim("preferred_username", "admin")
                        .claim("realm_access", Map.of("roles", List.of("admin")))
                    )
                    .authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))
                ))
            .andExpect(status().isOk());
    }

    @Test
    void getOrders_withoutRole_returns403() throws Exception {
        mockMvc.perform(get("/api/orders")
                .with(jwt()))
            .andExpect(status().isForbidden());
    }

    @Test
    void publicEndpoint_withoutToken_returns200() throws Exception {
        mockMvc.perform(get("/api/public/health"))
            .andExpect(status().isOk());
    }
}

운영 팁

  • JWKS 캐싱: Spring은 JWKS를 자동 캐싱하지만, 키 로테이션 시 NimbusJwtDecoder가 자동 리프레시
  • Clock Skew: JwtTimestampValidatorclockSkew를 30초~1분으로 설정해 서버 간 시간 차이 허용
  • 에러 응답: BearerTokenAuthenticationEntryPoint가 RFC 6750 표준 WWW-Authenticate 헤더 자동 생성
  • 성능: JWT는 네트워크 호출 없이 로컬 검증 → Rate Limiting과 함께 사용해도 오버헤드 최소화

정리

Spring OAuth2 Resource Server는 JWT/Opaque Token 기반 API 보안의 표준 구현입니다. 인증 서버 독립적인 토큰 검증, 커스텀 Authority 변환, SpEL 기반 메서드 시큐리티를 조합하면 역할·소유자·스코프 기반의 세밀한 접근 제어를 선언적으로 구현할 수 있습니다. Multi-Tenancy 패턴으로 복수 인증 서버도 단일 API에서 처리 가능합니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux