Resource Server với Keycloak: nhiều service cùng validate một token

5 câu hỏi~35 phútTừ khóa: oauth2ResourceServer, issuer-uri, JWKS, kid, RS256, audience, realm_access, resource_access, JwtAuthenticationConverter, Token Relay, Client Credentials

Còn mấy bài nữa?

Đã học 7 bài. Còn 3 bài chính nữa là xong phần Spring Security:

BàiNội dungTrạng thái
4.4.1–4.4.7Filter chain → OAuth2/OIDCĐã học
4.4.8Resource Server, Keycloak, security trong microservicesTiếp theo
4.4.9Password encoding, CORS, CSRFChưa học
4.4.10Method security và testChưa học

Tiếp đi: Resource Server và Keycloak.

Bài này kết nối Spring Security với microservices. shop-app giờ đã tách thành nhiều service, và việc login giao cho Keycloak.

1. Bài toán

Client (Web / Mobile)
      |
      v
 API Gateway
      |
      v
 order-service ---> payment-service ---> notification-service

John login qua Keycloak, nhận access token, rồi gọi:

POST /api/orders
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...

Ba câu hỏi:

  1. Gateway đã validate JWT rồi, order-service có cần validate lại không?
  2. order-service gọi payment-service thì truyền token của John hay token của service?
  3. Role của John trong Keycloak làm sao thành hasRole('ADMIN') trong Spring?

2. Vấn đề

3. Giải pháp: mỗi service là một Resource Server

Gateway và từng service đều validate JWT độc lập (defense in depth, zero trust). Keycloak ký token bằng RS256: private key chỉ Keycloak giữ, các service chỉ cần public key để verify cục bộ, không gọi Keycloak mỗi request.

4. Code từng bước

Bước 1: dependency

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

Bước 2: config (Keycloak chạy ở 8081, realm shop)

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8081/realms/shop
          # hoặc chỉ định thẳng, khỏi gọi discovery lúc khởi động:
          # jwk-set-uri: http://localhost:8081/realms/shop/protocol/openid-connect/certs
          audiences: order-service      # Spring Boot 3.2+

Bước 3: SecurityConfig + map role Keycloak

Token Keycloak trông thế này:

{
  "iss": "http://localhost:8081/realms/shop",
  "sub": "f1c2...-uuid",
  "aud": ["order-service"],
  "azp": "shop-web",
  "realm_access":    { "roles": ["USER", "ADMIN"] },
  "resource_access": { "order-service": { "roles": ["ORDER_DELETE"] } },
  "scope": "openid profile email"
}
@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(keycloakConverter())));
        return http.build();
    }

    private Converter<Jwt, AbstractAuthenticationToken> keycloakConverter() {
        JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter(); // SCOPE_xxx
        return jwt -> {
            Set<GrantedAuthority> authorities = new HashSet<>(scopes.convert(jwt));

            // realm roles -> ROLE_USER, ROLE_ADMIN
            Map<String, Object> realm = jwt.getClaimAsMap("realm_access");
            if (realm != null && realm.get("roles") instanceof Collection<?> roles) {
                roles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r)));
            }

            // client roles của chính service này -> ORDER_DELETE
            Map<String, Object> resource = jwt.getClaimAsMap("resource_access");
            if (resource != null && resource.get("order-service") instanceof Map<?, ?> client
                    && client.get("roles") instanceof Collection<?> roles) {
                roles.forEach(r -> authorities.add(new SimpleGrantedAuthority(r.toString())));
            }
            return new JwtAuthenticationToken(jwt, authorities, jwt.getClaimAsString("preferred_username"));
        };
    }
}

Bước 4: test bằng curl

# lấy token từ Keycloak (môi trường dev, client test)
TOKEN=$(curl -s -X POST http://localhost:8081/realms/shop/protocol/openid-connect/token \
  -d grant_type=client_credentials -d client_id=order-tester \
  -d client_secret=$TEST_CLIENT_SECRET | jq -r .access_token)

curl -H "Authorization: Bearer $TOKEN" localhost:8080/api/orders     # 200
curl localhost:8080/api/orders                                         # 401
curl -H "Authorization: Bearer $TOKEN" localhost:8080/api/admin/users # 403 nếu thiếu ROLE_ADMIN

5. Cách hoạt động

                 Keycloak (realm shop)
                 /.well-known/openid-configuration
                 /protocol/openid-connect/certs  (JWKS, public keys)
                         |  tải 1 lần, cache, refresh khi gặp kid lạ
       +-----------------+-----------------+
       v                 v                 v
  API Gateway       order-service     payment-service

Mỗi request tại mỗi service (BearerTokenAuthenticationFilter):
 1. Lấy token từ header Authorization
 2. Đọc kid trong header JWT -> chọn public key trong JWKS cache
 3. Verify chữ ký RS256
 4. Kiểm tra exp, nbf, iss (và aud nếu cấu hình)
 5. JwtAuthenticationConverter -> authorities
 6. Authorization: hasRole / @PreAuthorize

Keycloak down thì sao? Token đã phát vẫn được verify bằng key đã cache. Nhưng login, refresh, lấy token mới, tải key mới (khi Keycloak rotate key) sẽ lỗi. Phân biệt rõ: token issuance cần Keycloak, token validation thì không.

6. So sánh: ai validate và cách gọi xuống downstream

Phương ánƯuRủi ro
Chỉ gateway validate, truyền header user xuốngService đơn giảnGọi thẳng service / giả header là bypass
Gateway + từng service đều validateDefense in depth, service tự authorizationMỗi service cấu hình resource server (rẻ, verify cục bộ)
Chiến lược gọi service khácKhi nào dùng
Token RelayDownstream làm thay mặt user và token có aud phù hợp
Token ExchangeCần token mới, hẹp hơn, dành riêng cho downstream
Client CredentialsService tự làm việc, không có user (job, consumer)
mTLSXác thực danh tính service ở tầng kết nối, dùng kèm OAuth2

7. Khi nào nên dùng / không nên dùng

Gateway nhận token rồi thì làm sao chuyển token xuống order-service?

Có hai trường hợp, tùy gateway là resource server hay OAuth2 client.

Gateway là resource server (client gửi Bearer token): Spring Cloud Gateway mặc định giữ nguyên header Authorization khi forward. Gateway validate sớm để chặn rác, service validate lại.

spring:
  cloud:
    gateway:
      routes:
        - id: orders
          uri: lb://order-service
          predicates: [ Path=/api/orders/** ]
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8081/realms/shop

Gateway là OAuth2 client / BFF (browser login qua gateway, giữ session cookie): gateway giữ token trong session và dùng filter TokenRelay để gắn access token vào request xuống service:

spring:
  cloud:
    gateway:
      default-filters:
        - TokenRelay=
Browser --cookie--> Gateway (oauth2Login, giữ token) --Bearer token--> order-service

Cách BFF này giúp token không bao giờ xuống browser, nhưng gateway giữ session nên phải tính CSRF (bài 4.4.9).

Còn order-service gọi payment-service bằng Client Credentials thì code sao?

Spring có sẵn OAuth2AuthorizedClientManager: tự xin token, cache và xin lại khi hết hạn.

spring:
  security:
    oauth2:
      client:
        registration:
          payment-client:
            provider: keycloak
            client-id: order-service
            client-secret: ${ORDER_SERVICE_SECRET}
            authorization-grant-type: client_credentials
        provider:
          keycloak:
            token-uri: http://localhost:8081/realms/shop/protocol/openid-connect/token
@Configuration
public class OAuth2ClientConfig {
    @Bean
    OAuth2AuthorizedClientManager clientManager(ClientRegistrationRepository registrations,
                                                OAuth2AuthorizedClientService clientService) {
        var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clientService);
        manager.setAuthorizedClientProvider(
            OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials().build());
        return manager;
    }
}

@Service
public class PaymentClient {
    private final OAuth2AuthorizedClientManager manager;
    private final RestTemplate restTemplate;

    public PaymentClient(OAuth2AuthorizedClientManager manager, RestTemplate restTemplate) {
        this.manager = manager;
        this.restTemplate = restTemplate;
    }

    public String getPayments() {
        OAuth2AuthorizedClient client = manager.authorize(
            OAuth2AuthorizeRequest.withClientRegistrationId("payment-client")
                .principal("order-service").build());
        if (client == null) throw new IllegalStateException("Cannot authorize OAuth2 client");

        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(client.getAccessToken().getTokenValue());
        return restTemplate.exchange("http://payment-service/api/payments",
            HttpMethod.GET, new HttpEntity<>(headers), String.class).getBody();
    }
}
User tokenService token
Đại diệnNgười dùngỨng dụng/service
FlowAuthorization Code + PKCEClient Credentials
Ví dụJohn tạo orderJob retry payment

Lưu ý: service token không tự có mọi quyền của user. payment-service vẫn phải kiểm tra nghiệp vụ.

Phỏng vấn hỏi về resource server và Keycloak thì trả lời sao?

Q1. Mỗi microservice có cần validate JWT không, hay chỉ gateway?

🇻🇳 Tiếng Việt:

Em cho mỗi service là một resource server và tự validate JWT, gateway chỉ là lớp chặn sớm. Như vậy gọi thẳng service hay giả header không bypass được, và mỗi service tự áp authorization nghiệp vụ. Chi phí thấp vì JWT RS256 verify cục bộ bằng public key đã cache.

🇬🇧 English:

I make every service a resource server that validates the JWT itself, and the gateway is only an early filter. That way, calling a service directly or forging headers does not bypass security, and each service enforces its own business authorization. It is cheap because RS256 tokens are verified locally with cached public keys.

Q2. Spring Resource Server verify JWT của Keycloak thế nào?

🇻🇳 Tiếng Việt:

Từ issuer-uri, Spring đọc discovery document để lấy jwks_uri, tải và cache public key. Mỗi request nó chọn key theo kid, verify chữ ký, kiểm tra exp, iss và aud, rồi dùng converter map claim thành authorities.

🇬🇧 English:

From the issuer-uri, Spring reads the discovery document to find the jwks_uri, then downloads and caches the public keys. On each request it picks the key by kid, verifies the signature, checks exp, iss and aud, and uses a converter to map claims to authorities.

Q3. Làm sao dùng role Keycloak với hasRole?

🇻🇳 Tiếng Việt:

Mặc định Spring chỉ map claim scope thành SCOPE_xxx. Keycloak để realm role trong realm_access.roles và client role trong resource_access.{client}.roles, nên em viết JwtAuthenticationConverter riêng, thêm prefix ROLE_ cho realm role.

🇬🇧 English:

By default Spring only maps the scope claim to SCOPE_xxx authorities. Keycloak puts realm roles in realm_access.roles and client roles in resource_access.{client}.roles, so I write a custom JwtAuthenticationConverter that adds the ROLE_ prefix to realm roles.

Q4. Keycloak down thì hệ thống ra sao?

🇻🇳 Tiếng Việt:

Token đã phát vẫn được verify bằng public key đã cache. Nhưng login, refresh token và client credentials mới sẽ lỗi, nên Keycloak phải chạy cluster, có HA.

🇬🇧 English:

Existing tokens are still verified with cached public keys, but login, token refresh and new client-credentials tokens fail, so Keycloak must run as a highly available cluster.

Cách nhớ ngắn gọn


Tự kiểm tra

Vì sao resource server không cần private key của Keycloak?

RS256 là ký bất đối xứng: Keycloak ký bằng private key, service verify bằng public key lấy từ JWKS.

Token có realm_access.roles: ["ADMIN"] nhưng hasRole('ADMIN') vẫn 403. Vì sao?

Converter mặc định chỉ đọc scope. Cần custom JwtAuthenticationConverter map realm role thành ROLE_ADMIN.

Không kiểm tra aud thì rủi ro gì?

Token cấp cho app/service khác (cùng issuer) cũng được chấp nhận, mở rộng phạm vi token bị lộ.

Filter TokenRelay của Gateway dùng khi nào?

Khi gateway là OAuth2 client (oauth2Login/BFF) giữ token trong session và cần gắn access token vào request xuống service.

Scheduled job retry payment nên dùng token nào?

Service token qua Client Credentials, quyền tối thiểu; không dùng token admin hay hardcode token.